floq 1.3.2 → 1.4.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.
@@ -0,0 +1,272 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useState, useEffect, useMemo } from 'react';
3
+ import { Box, Text, useInput } from 'ink';
4
+ import { eq, and, gte } from 'drizzle-orm';
5
+ import { getDb, schema } from '../../db/index.js';
6
+ import { t, fmt } from '../../i18n/index.js';
7
+ import { useTheme } from '../theme/index.js';
8
+ const VISIBLE_LINES = 16;
9
+ function stringWidth(str) {
10
+ let width = 0;
11
+ for (const char of str) {
12
+ const code = char.codePointAt(0) || 0;
13
+ if ((code >= 0x1100 && code <= 0x115F) ||
14
+ (code >= 0x2E80 && code <= 0x303E) ||
15
+ (code >= 0x3040 && code <= 0x9FFF) ||
16
+ (code >= 0xAC00 && code <= 0xD7AF) ||
17
+ (code >= 0xF900 && code <= 0xFAFF) ||
18
+ (code >= 0xFE30 && code <= 0xFE6F) ||
19
+ (code >= 0xFF01 && code <= 0xFF60) ||
20
+ (code >= 0xFFE0 && code <= 0xFFE6) ||
21
+ (code >= 0x20000 && code <= 0x2FFFF)) {
22
+ width += 2;
23
+ }
24
+ else {
25
+ width += 1;
26
+ }
27
+ }
28
+ return width;
29
+ }
30
+ function padEndCJK(str, targetWidth) {
31
+ const currentWidth = stringWidth(str);
32
+ const padding = Math.max(0, targetWidth - currentWidth);
33
+ return str + ' '.repeat(padding);
34
+ }
35
+ function bar(count, max, width = 15) {
36
+ if (max === 0)
37
+ return '';
38
+ const filled = Math.round((count / max) * width);
39
+ return '█'.repeat(filled);
40
+ }
41
+ function getWeekStart(date) {
42
+ const d = new Date(date);
43
+ d.setDate(d.getDate() - d.getDay());
44
+ d.setHours(0, 0, 0, 0);
45
+ return d;
46
+ }
47
+ export function InsightsModal({ onClose }) {
48
+ const [scrollOffset, setScrollOffset] = useState(0);
49
+ const [content, setContent] = useState([]);
50
+ const [loading, setLoading] = useState(true);
51
+ const theme = useTheme();
52
+ const i18n = t();
53
+ const ins = i18n.commands.insights;
54
+ const isJa = ins?.title === 'タスクインサイト';
55
+ const l = useMemo(() => ({
56
+ title: ins?.title || 'Task Insights',
57
+ period: ins?.period || 'Period',
58
+ weeklyCompletion: ins?.weeklyCompletion || 'Weekly Completion',
59
+ weekLabel: ins?.weekLabel || 'Week of {date}',
60
+ tasksCompleted: ins?.tasksCompleted || '{count} tasks completed',
61
+ dailyBreakdown: ins?.dailyBreakdown || 'Daily Breakdown',
62
+ currentStatus: ins?.currentStatus || 'Current Status',
63
+ byContext: ins?.byContext || 'By Context',
64
+ byEffort: ins?.byEffort || 'By Effort',
65
+ noContext: ins?.noContext || 'No context',
66
+ noEffort: ins?.noEffort || 'No effort set',
67
+ projectProgress: ins?.projectProgress || 'Project Progress',
68
+ activeProjects: ins?.activeProjects || 'Active Projects',
69
+ averageCompletion: ins?.averageCompletion || 'Average Completion Time',
70
+ daysAverage: ins?.daysAverage || '{days} days',
71
+ noData: ins?.noData || 'No completed tasks in this period',
72
+ total: ins?.total || 'Total',
73
+ andMore: ins?.andMore || ' ... and {count} more',
74
+ }), [ins]);
75
+ useEffect(() => {
76
+ const loadInsights = async () => {
77
+ const db = getDb();
78
+ const weeks = 2;
79
+ const lines = [];
80
+ const now = new Date();
81
+ const startDate = getWeekStart(now);
82
+ startDate.setDate(startDate.getDate() - (weeks - 1) * 7);
83
+ // Query completed tasks
84
+ const completedTasks = await db
85
+ .select()
86
+ .from(schema.tasks)
87
+ .where(and(eq(schema.tasks.status, 'done'), gte(schema.tasks.updatedAt, startDate), eq(schema.tasks.isProject, false)));
88
+ // Period header
89
+ lines.push({ type: 'text', value: `${l.period}: ${startDate.toLocaleDateString()} ~ ${now.toLocaleDateString()}` });
90
+ lines.push({ type: 'spacer', value: '' });
91
+ if (completedTasks.length === 0) {
92
+ lines.push({ type: 'text', value: l.noData });
93
+ setContent(lines);
94
+ setLoading(false);
95
+ return;
96
+ }
97
+ // Weekly completion
98
+ lines.push({ type: 'header', value: l.weeklyCompletion });
99
+ for (let i = 0; i < weeks; i++) {
100
+ const ws = getWeekStart(now);
101
+ ws.setDate(ws.getDate() - i * 7);
102
+ const we = new Date(ws);
103
+ we.setDate(we.getDate() + 7);
104
+ const weekTasks = completedTasks.filter(t => t.updatedAt >= ws && t.updatedAt < we);
105
+ const weekLabel = fmt(l.weekLabel, { date: ws.toLocaleDateString() });
106
+ const countLabel = fmt(l.tasksCompleted, { count: weekTasks.length });
107
+ lines.push({ type: 'text', value: `${weekLabel}: ${countLabel}` });
108
+ const maxShow = 5;
109
+ for (let j = 0; j < Math.min(weekTasks.length, maxShow); j++) {
110
+ const task = weekTasks[j];
111
+ lines.push({ type: 'text', value: ` [${task.id.slice(0, 8)}] ${task.title}` });
112
+ }
113
+ if (weekTasks.length > maxShow) {
114
+ lines.push({ type: 'text', value: fmt(l.andMore, { count: weekTasks.length - maxShow }) });
115
+ }
116
+ }
117
+ lines.push({ type: 'spacer', value: '' });
118
+ // Daily breakdown
119
+ lines.push({ type: 'header', value: l.dailyBreakdown });
120
+ const dayCounts = new Array(7).fill(0);
121
+ for (const task of completedTasks) {
122
+ dayCounts[task.updatedAt.getDay()]++;
123
+ }
124
+ const maxDaily = Math.max(...dayCounts);
125
+ const dayNames = isJa
126
+ ? ['日', '月', '火', '水', '木', '金', '土']
127
+ : ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
128
+ for (let i = 0; i < 7; i++) {
129
+ const name = padEndCJK(dayNames[i], 4);
130
+ const count = String(dayCounts[i]).padStart(3);
131
+ lines.push({ type: 'bar', value: ` ${name}${count}`, barValue: dayCounts[i], barMax: maxDaily });
132
+ }
133
+ lines.push({ type: 'spacer', value: '' });
134
+ // Current status distribution
135
+ lines.push({ type: 'header', value: l.currentStatus });
136
+ const allTasks = await db
137
+ .select()
138
+ .from(schema.tasks)
139
+ .where(eq(schema.tasks.isProject, false));
140
+ const statusCounts = new Map();
141
+ for (const task of allTasks) {
142
+ statusCounts.set(task.status, (statusCounts.get(task.status) || 0) + 1);
143
+ }
144
+ const statusOrder = ['inbox', 'next', 'waiting', 'someday', 'done'];
145
+ const maxStatus = Math.max(...statusCounts.values());
146
+ for (const status of statusOrder) {
147
+ const count = statusCounts.get(status) || 0;
148
+ const label = padEndCJK(i18n.status[status] || status, 16);
149
+ const countStr = String(count).padStart(3);
150
+ lines.push({ type: 'bar', value: ` ${label}${countStr}`, barValue: count, barMax: maxStatus });
151
+ }
152
+ lines.push({ type: 'spacer', value: '' });
153
+ // Context distribution
154
+ lines.push({ type: 'header', value: l.byContext });
155
+ const contextMap = new Map();
156
+ for (const task of completedTasks) {
157
+ const key = task.context || l.noContext;
158
+ contextMap.set(key, (contextMap.get(key) || 0) + 1);
159
+ }
160
+ const contextEntries = Array.from(contextMap.entries()).sort((a, b) => b[1] - a[1]);
161
+ const maxContext = contextEntries.length > 0 ? contextEntries[0][1] : 0;
162
+ for (const [ctx, count] of contextEntries) {
163
+ const displayLabel = ctx === l.noContext ? ctx : `@${ctx}`;
164
+ const label = padEndCJK(displayLabel, 16);
165
+ const pct = Math.round((count / completedTasks.length) * 100);
166
+ const countStr = String(count).padStart(3);
167
+ lines.push({ type: 'bar', value: ` ${label}${countStr} (${String(pct).padStart(2)}%)`, barValue: count, barMax: maxContext });
168
+ }
169
+ lines.push({ type: 'spacer', value: '' });
170
+ // Effort distribution
171
+ lines.push({ type: 'header', value: l.byEffort });
172
+ const effortLabels = {
173
+ small: i18n.tui.effort?.small || 'Small',
174
+ medium: i18n.tui.effort?.medium || 'Medium',
175
+ large: i18n.tui.effort?.large || 'Large',
176
+ };
177
+ const effortMap = new Map();
178
+ for (const task of completedTasks) {
179
+ const key = task.effort ? (effortLabels[task.effort] || task.effort) : l.noEffort;
180
+ effortMap.set(key, (effortMap.get(key) || 0) + 1);
181
+ }
182
+ const effortEntries = Array.from(effortMap.entries()).sort((a, b) => b[1] - a[1]);
183
+ const maxEffort = effortEntries.length > 0 ? effortEntries[0][1] : 0;
184
+ for (const [eff, count] of effortEntries) {
185
+ const label = padEndCJK(eff, 16);
186
+ const pct = Math.round((count / completedTasks.length) * 100);
187
+ const countStr = String(count).padStart(3);
188
+ lines.push({ type: 'bar', value: ` ${label}${countStr} (${String(pct).padStart(2)}%)`, barValue: count, barMax: maxEffort });
189
+ }
190
+ lines.push({ type: 'spacer', value: '' });
191
+ // Project progress
192
+ lines.push({ type: 'header', value: l.projectProgress });
193
+ const activeProjects = await db
194
+ .select()
195
+ .from(schema.tasks)
196
+ .where(and(eq(schema.tasks.isProject, true), eq(schema.tasks.status, 'next')));
197
+ lines.push({ type: 'text', value: ` ${l.activeProjects}: ${activeProjects.length}` });
198
+ for (const project of activeProjects) {
199
+ const children = await db
200
+ .select()
201
+ .from(schema.tasks)
202
+ .where(eq(schema.tasks.parentId, project.id));
203
+ const total = children.length;
204
+ const done = children.filter(c => c.status === 'done').length;
205
+ const pct = total > 0 ? Math.round((done / total) * 100) : 0;
206
+ lines.push({ type: 'text', value: ` [${project.id.slice(0, 8)}] ${project.title} (${done}/${total}, ${pct}%)` });
207
+ }
208
+ lines.push({ type: 'spacer', value: '' });
209
+ // Average completion time
210
+ lines.push({ type: 'header', value: l.averageCompletion });
211
+ let totalMs = 0;
212
+ let validCount = 0;
213
+ for (const task of completedTasks) {
214
+ const diff = task.updatedAt.getTime() - task.createdAt.getTime();
215
+ if (diff > 0) {
216
+ totalMs += diff;
217
+ validCount++;
218
+ }
219
+ }
220
+ if (validCount > 0) {
221
+ const avgDays = totalMs / validCount / (1000 * 60 * 60 * 24);
222
+ if (avgDays < 1) {
223
+ const hours = Math.round(avgDays * 24);
224
+ const hoursLabel = isJa ? `${hours}時間` : `${hours}h`;
225
+ lines.push({ type: 'text', value: ` ${hoursLabel}` });
226
+ }
227
+ else {
228
+ lines.push({ type: 'text', value: ` ${fmt(l.daysAverage, { days: avgDays.toFixed(1) })}` });
229
+ }
230
+ }
231
+ lines.push({ type: 'spacer', value: '' });
232
+ // Total
233
+ lines.push({ type: 'text', value: `${l.total}: ${fmt(l.tasksCompleted, { count: completedTasks.length })}` });
234
+ setContent(lines);
235
+ setLoading(false);
236
+ };
237
+ loadInsights();
238
+ }, [l, isJa, i18n]);
239
+ const maxScroll = Math.max(0, content.length - VISIBLE_LINES);
240
+ useInput((input, key) => {
241
+ if (input === 'j' || key.downArrow) {
242
+ setScrollOffset(prev => Math.min(prev + 1, maxScroll));
243
+ return;
244
+ }
245
+ if (input === 'k' || key.upArrow) {
246
+ setScrollOffset(prev => Math.max(prev - 1, 0));
247
+ return;
248
+ }
249
+ if (key.escape || key.return || input === 'q' || input === ' ') {
250
+ onClose();
251
+ return;
252
+ }
253
+ });
254
+ const visibleContent = content.slice(scrollOffset, scrollOffset + VISIBLE_LINES);
255
+ const showScrollUp = scrollOffset > 0;
256
+ const showScrollDown = scrollOffset < maxScroll;
257
+ const formatTitle = (title) => theme.style.headerUppercase ? title.toUpperCase() : title;
258
+ const renderLine = (line, index) => {
259
+ switch (line.type) {
260
+ case 'header':
261
+ return (_jsx(Text, { bold: true, color: theme.colors.accent, children: formatTitle(line.value) }, index));
262
+ case 'bar':
263
+ return (_jsxs(Text, { color: theme.colors.text, children: [line.value, " ", _jsx(Text, { color: theme.colors.secondary, children: bar(line.barValue || 0, line.barMax || 0) })] }, index));
264
+ case 'spacer':
265
+ return _jsx(Text, { children: " " }, index);
266
+ default:
267
+ return (_jsx(Text, { color: theme.colors.text, children: line.value }, index));
268
+ }
269
+ };
270
+ const closeHint = isJa ? 'Esc/q: 閉じる' : 'Esc/q: close';
271
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: theme.borders.modal, borderColor: theme.colors.borderActive, paddingX: 2, paddingY: 1, children: [_jsx(Box, { justifyContent: "center", marginBottom: 1, children: _jsx(Text, { bold: true, color: theme.colors.secondary, children: formatTitle(l.title) }) }), _jsx(Box, { flexDirection: "column", height: VISIBLE_LINES + 2, children: loading ? (_jsx(Text, { color: theme.colors.textMuted, children: isJa ? '読み込み中...' : 'Loading...' })) : (_jsxs(_Fragment, { children: [showScrollUp && (_jsx(Text, { color: theme.colors.textMuted, children: " \u25B2 scroll up (k)" })), !showScrollUp && _jsx(Text, { children: " " }), visibleContent.map((line, index) => renderLine(line, index)), showScrollDown && (_jsx(Text, { color: theme.colors.textMuted, children: " \u25BC scroll down (j)" })), !showScrollDown && _jsx(Text, { children: " " })] })) }), _jsx(Box, { justifyContent: "center", marginTop: 1, children: _jsxs(Text, { color: theme.colors.textMuted, children: [maxScroll > 0 ? 'j/k: scroll | ' : '', closeHint] }) })] }));
272
+ }
@@ -6,6 +6,7 @@ import { eq, and, inArray, gte } from 'drizzle-orm';
6
6
  import { v4 as uuidv4 } from 'uuid';
7
7
  import { KanbanColumn } from './KanbanColumn.js';
8
8
  import { HelpModal } from './HelpModal.js';
9
+ import { InsightsModal } from './InsightsModal.js';
9
10
  import { CalendarModal } from './CalendarModal.js';
10
11
  import { FunctionKeyBar } from './FunctionKeyBar.js';
11
12
  import { SearchBar } from './SearchBar.js';
@@ -15,9 +16,9 @@ import { CalendarEvents } from './CalendarEvents.js';
15
16
  import { getDb, schema } from '../../db/index.js';
16
17
  import { t, fmt } from '../../i18n/index.js';
17
18
  import { useTheme } from '../theme/index.js';
18
- import { isTursoEnabled, getContexts, addContext, getContextFilter, setContextFilter as saveContextFilter } from '../../config.js';
19
+ import { isTursoEnabled, getContexts, addContext, getContextFilter, setContextFilter as saveContextFilter, getFocusFilter, setFocusFilter } from '../../config.js';
19
20
  import { VERSION } from '../../version.js';
20
- import { useHistory, CreateTaskCommand, MoveTaskCommand, LinkTaskCommand, CreateCommentCommand, DeleteCommentCommand, SetContextCommand, } from '../history/index.js';
21
+ import { useHistory, CreateTaskCommand, MoveTaskCommand, LinkTaskCommand, CreateCommentCommand, DeleteCommentCommand, SetContextCommand, SetFocusCommand, SetEffortCommand, } from '../history/index.js';
21
22
  const COLUMNS = ['todo', 'doing', 'done'];
22
23
  export function KanbanBoard({ onSwitchToGtd, onOpenSettings }) {
23
24
  const theme = useTheme();
@@ -54,7 +55,15 @@ export function KanbanBoard({ onSwitchToGtd, onOpenSettings }) {
54
55
  }, []);
55
56
  const [contextSelectIndex, setContextSelectIndex] = useState(0);
56
57
  const [availableContexts, setAvailableContexts] = useState([]);
58
+ const [focusFilter, setFocusFilterState] = useState(getFocusFilter());
59
+ const [effortSelectIndex, setEffortSelectIndex] = useState(0);
57
60
  const i18n = t();
61
+ const EFFORT_OPTIONS = [
62
+ { value: 'small', label: i18n.tui.effort?.small || 'Small' },
63
+ { value: 'medium', label: i18n.tui.effort?.medium || 'Medium' },
64
+ { value: 'large', label: i18n.tui.effort?.large || 'Large' },
65
+ { value: null, label: i18n.tui.effort?.clear || 'Clear' },
66
+ ];
58
67
  // Status mapping:
59
68
  // TODO = inbox + someday
60
69
  // Doing = next + waiting
@@ -69,6 +78,12 @@ export function KanbanBoard({ onSwitchToGtd, onOpenSettings }) {
69
78
  return taskList.filter(t => !t.context);
70
79
  return taskList.filter(t => t.context === contextFilter);
71
80
  };
81
+ // Apply focus filter helper
82
+ const filterByFocus = (taskList) => {
83
+ if (!focusFilter)
84
+ return taskList;
85
+ return taskList.filter(t => t.isFocused);
86
+ };
72
87
  // TODO: inbox + someday (non-project tasks)
73
88
  let todoTasks = await db
74
89
  .select()
@@ -92,13 +107,13 @@ export function KanbanBoard({ onSwitchToGtd, onOpenSettings }) {
92
107
  .from(schema.tasks)
93
108
  .where(and(eq(schema.tasks.isProject, true), eq(schema.tasks.status, 'next')));
94
109
  setTasks({
95
- todo: filterByContext(todoTasks),
96
- doing: filterByContext(doingTasks),
97
- done: filterByContext(doneTasks),
110
+ todo: filterByFocus(filterByContext(todoTasks)),
111
+ doing: filterByFocus(filterByContext(doingTasks)),
112
+ done: filterByFocus(filterByContext(doneTasks)),
98
113
  });
99
114
  setProjects(projectTasks);
100
115
  setAvailableContexts(getContexts());
101
- }, [contextFilter]);
116
+ }, [contextFilter, focusFilter]);
102
117
  useEffect(() => {
103
118
  loadTasks();
104
119
  }, [loadTasks]);
@@ -150,6 +165,11 @@ export function KanbanBoard({ onSwitchToGtd, onOpenSettings }) {
150
165
  const currentColumn = COLUMNS[currentColumnIndex];
151
166
  const currentTasks = tasks[currentColumn];
152
167
  const selectedTaskIndex = selectedTaskIndices[currentColumn];
168
+ const getCurrentTask = () => {
169
+ if (currentTasks.length === 0)
170
+ return undefined;
171
+ return currentTasks[selectedTaskIndex];
172
+ };
153
173
  // Get all tasks for search
154
174
  const getAllTasks = useCallback(() => {
155
175
  const allTasks = [];
@@ -346,13 +366,54 @@ export function KanbanBoard({ onSwitchToGtd, onOpenSettings }) {
346
366
  setMessage(description);
347
367
  await loadTasks();
348
368
  }, [i18n.tui.context, loadTasks, history]);
369
+ const toggleTaskFocus = async () => {
370
+ const task = getCurrentTask();
371
+ if (!task)
372
+ return;
373
+ const newFocused = !task.isFocused;
374
+ const description = newFocused
375
+ ? fmt(i18n.tui.focus?.taskFocused || 'Focused: "{title}"', { title: task.title })
376
+ : fmt(i18n.tui.focus?.taskUnfocused || 'Unfocused: "{title}"', { title: task.title });
377
+ const command = new SetFocusCommand({
378
+ taskId: task.id,
379
+ fromFocused: task.isFocused,
380
+ toFocused: newFocused,
381
+ description,
382
+ });
383
+ await history.execute(command);
384
+ setMessage(description);
385
+ await loadTasks();
386
+ };
387
+ const toggleFocusFilter = () => {
388
+ const newValue = !focusFilter;
389
+ setFocusFilterState(newValue);
390
+ setFocusFilter(newValue);
391
+ setMessage(newValue ? (i18n.tui.focus?.filterOn || 'Focus filter ON') : (i18n.tui.focus?.filterOff || 'Focus filter OFF'));
392
+ };
393
+ const setTaskEffort = async (effort) => {
394
+ const task = getCurrentTask();
395
+ if (!task)
396
+ return;
397
+ const description = effort
398
+ ? fmt(i18n.tui.effort?.effortSet || 'Set effort {effort} for "{title}"', { effort: i18n.tui.effort?.[effort] || effort, title: task.title })
399
+ : fmt(i18n.tui.effort?.effortCleared || 'Cleared effort for "{title}"', { title: task.title });
400
+ const command = new SetEffortCommand({
401
+ taskId: task.id,
402
+ fromEffort: task.effort,
403
+ toEffort: effort,
404
+ description,
405
+ });
406
+ await history.execute(command);
407
+ setMessage(description);
408
+ setMode('normal');
409
+ await loadTasks();
410
+ };
349
411
  const getColumnLabel = (column) => {
350
412
  return i18n.kanban[column];
351
413
  };
352
414
  useInput((input, key) => {
353
- // Handle help mode - any key closes
354
- if (mode === 'help') {
355
- setMode('normal');
415
+ // Handle help/insights mode - let modals handle their own input
416
+ if (mode === 'help' || mode === 'insights') {
356
417
  return;
357
418
  }
358
419
  // Handle search mode
@@ -533,6 +594,26 @@ export function KanbanBoard({ onSwitchToGtd, onOpenSettings }) {
533
594
  }
534
595
  return;
535
596
  }
597
+ // Handle set-effort mode
598
+ if (mode === 'set-effort') {
599
+ if (key.escape) {
600
+ setMode('normal');
601
+ return;
602
+ }
603
+ if (input === 'j' || key.downArrow) {
604
+ setEffortSelectIndex(prev => Math.min(prev + 1, EFFORT_OPTIONS.length - 1));
605
+ return;
606
+ }
607
+ if (input === 'k' || key.upArrow) {
608
+ setEffortSelectIndex(prev => Math.max(prev - 1, 0));
609
+ return;
610
+ }
611
+ if (key.return) {
612
+ setTaskEffort(EFFORT_OPTIONS[effortSelectIndex].value);
613
+ return;
614
+ }
615
+ return;
616
+ }
536
617
  // Clear message on any input
537
618
  if (message) {
538
619
  setMessage(null);
@@ -542,6 +623,11 @@ export function KanbanBoard({ onSwitchToGtd, onOpenSettings }) {
542
623
  setMode('help');
543
624
  return;
544
625
  }
626
+ // Show insights
627
+ if (input === 'I') {
628
+ setMode('insights');
629
+ return;
630
+ }
545
631
  // Show calendar
546
632
  if (input === 'C') {
547
633
  setMode('calendar');
@@ -567,6 +653,24 @@ export function KanbanBoard({ onSwitchToGtd, onOpenSettings }) {
567
653
  setMode('set-context');
568
654
  return;
569
655
  }
656
+ // Toggle task focus
657
+ if (input === 'g') {
658
+ toggleTaskFocus();
659
+ return;
660
+ }
661
+ // Toggle focus filter
662
+ if (input === 'G') {
663
+ toggleFocusFilter();
664
+ return;
665
+ }
666
+ // Set effort
667
+ if (input === 'E') {
668
+ if (getCurrentTask()) {
669
+ setEffortSelectIndex(0);
670
+ setMode('set-effort');
671
+ }
672
+ return;
673
+ }
570
674
  // Settings: Theme selector
571
675
  if (input === 'T' && onOpenSettings) {
572
676
  onOpenSettings('theme-select');
@@ -719,6 +823,10 @@ export function KanbanBoard({ onSwitchToGtd, onOpenSettings }) {
719
823
  if (mode === 'help') {
720
824
  return (_jsx(Box, { flexDirection: "column", padding: 1, children: _jsx(HelpModal, { onClose: () => setMode('normal'), isKanban: true }) }));
721
825
  }
826
+ // Insights modal overlay
827
+ if (mode === 'insights') {
828
+ return (_jsx(Box, { flexDirection: "column", padding: 1, children: _jsx(InsightsModal, { onClose: () => setMode('normal') }) }));
829
+ }
722
830
  // Calendar modal overlay
723
831
  if (mode === 'calendar') {
724
832
  return (_jsx(Box, { flexDirection: "column", padding: 1, children: _jsx(CalendarModal, { onClose: () => setMode('normal') }) }));
@@ -728,7 +836,7 @@ export function KanbanBoard({ onSwitchToGtd, onOpenSettings }) {
728
836
  const tursoEnabled = isTursoEnabled();
729
837
  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}` }), _jsx(Text, { color: tursoEnabled ? theme.colors.accent : theme.colors.textMuted, children: theme.name === 'modern'
730
838
  ? (tursoEnabled ? ' ☁️ turso' : ' 💾 local')
731
- : (tursoEnabled ? ' [DB]TURSO' : ' [DB]local') }), contextFilter !== null && (_jsxs(Text, { color: theme.colors.accent, children: [' ', "@", contextFilter === '' ? (i18n.tui.context?.none || 'none') : contextFilter] }))] }), _jsxs(Box, { children: [_jsx(CalendarEvents, { compact: true, showLabel: true, withSeparator: true }), _jsx(Clock, {}), _jsxs(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(Box, { marginTop: 1, children: [_jsxs(Text, { color: theme.colors.secondary, bold: true, children: [i18n.tui.taskDetailStatus, ": "] }), _jsxs(Text, { color: theme.colors.accent, children: [i18n.status[selectedTask.status], selectedTask.waitingFor && ` (${selectedTask.waitingFor})`] })] }), _jsxs(Box, { children: [_jsxs(Text, { color: theme.colors.secondary, bold: true, children: [i18n.tui.context?.label || 'Context', ": "] }), _jsx(Text, { color: theme.colors.accent, children: selectedTask.context ? `@${selectedTask.context}` : (i18n.tui.context?.none || 'No context') })] }), selectedTask.dueDate && (_jsxs(Text, { color: theme.colors.textMuted, children: ["Due: ", 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) => {
839
+ : (tursoEnabled ? ' [DB]TURSO' : ' [DB]local') }), contextFilter !== null && (_jsxs(Text, { color: theme.colors.accent, children: [' ', "@", contextFilter === '' ? (i18n.tui.context?.none || 'none') : contextFilter] })), focusFilter && (_jsxs(Text, { color: theme.colors.accent, children: [" ", i18n.tui.focus?.focused || '★ Focused'] }))] }), _jsxs(Box, { children: [_jsx(CalendarEvents, { compact: true, showLabel: true, withSeparator: true }), _jsx(Clock, {}), _jsxs(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(Box, { marginTop: 1, children: [_jsxs(Text, { color: theme.colors.secondary, bold: true, children: [i18n.tui.taskDetailStatus, ": "] }), _jsxs(Text, { color: theme.colors.accent, children: [i18n.status[selectedTask.status], selectedTask.waitingFor && ` (${selectedTask.waitingFor})`] })] }), _jsxs(Box, { children: [_jsxs(Text, { color: theme.colors.secondary, bold: true, children: [i18n.tui.context?.label || 'Context', ": "] }), _jsx(Text, { color: theme.colors.accent, children: selectedTask.context ? `@${selectedTask.context}` : (i18n.tui.context?.none || 'No context') })] }), _jsxs(Box, { children: [_jsxs(Text, { color: theme.colors.secondary, bold: true, children: [i18n.tui.effort?.label || 'Effort', ": "] }), _jsx(Text, { color: theme.colors.accent, children: selectedTask.effort ? (i18n.tui.effort?.[selectedTask.effort] || selectedTask.effort) : '-' })] }), _jsxs(Box, { children: [_jsxs(Text, { color: theme.colors.secondary, bold: true, children: [i18n.tui.focus?.label || 'Focus', ": "] }), _jsx(Text, { color: theme.colors.accent, children: selectedTask.isFocused ? '★' : '-' })] }), selectedTask.dueDate && (_jsxs(Text, { color: theme.colors.textMuted, children: ["Due: ", 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) => {
732
840
  const isSelected = index === selectedCommentIndex && mode === 'task-detail';
733
841
  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));
734
842
  })) }), 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, viewMode: "kanban" })) })) : mode === 'context-filter' ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: theme.colors.secondary, bold: true, children: i18n.tui.context?.filter || 'Filter by context' }), _jsx(Box, { flexDirection: "column", marginTop: 1, borderStyle: theme.borders.list, borderColor: theme.colors.borderActive, paddingX: 1, children: ['all', 'none', ...availableContexts].map((ctx, index) => {
@@ -751,7 +859,7 @@ export function KanbanBoard({ onSwitchToGtd, onOpenSettings }) {
751
859
  const isActive = (ctx === 'clear' && !currentContext) ||
752
860
  (ctx !== 'clear' && ctx !== 'new' && currentContext === ctx);
753
861
  return (_jsxs(Text, { color: index === contextSelectIndex ? theme.colors.textSelected : theme.colors.text, bold: index === contextSelectIndex, children: [index === contextSelectIndex ? theme.style.selectedPrefix : theme.style.unselectedPrefix, label, isActive && ' *'] }, ctx));
754
- }) }), _jsx(Text, { color: theme.colors.textMuted, children: i18n.tui.context?.setContextHelp || 'j/k: select, Enter: confirm, Esc: cancel' })] })) : mode === 'add-context' ? (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: theme.colors.secondary, bold: true, children: i18n.tui.context?.newContext || 'New context: ' }), _jsx(TextInput, { value: inputValue, onChange: setInputValue, onSubmit: handleInputSubmit, placeholder: i18n.tui.context?.newContextPlaceholder || 'Enter context name...' }), _jsxs(Text, { color: theme.colors.textMuted, children: [" ", i18n.tui.inputHelp] })] })) : (_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: [
862
+ }) }), _jsx(Text, { color: theme.colors.textMuted, children: i18n.tui.context?.setContextHelp || 'j/k: select, Enter: confirm, Esc: cancel' })] })) : mode === 'add-context' ? (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: theme.colors.secondary, bold: true, children: i18n.tui.context?.newContext || 'New context: ' }), _jsx(TextInput, { value: inputValue, onChange: setInputValue, onSubmit: handleInputSubmit, placeholder: i18n.tui.context?.newContextPlaceholder || 'Enter context name...' }), _jsxs(Text, { color: theme.colors.textMuted, children: [" ", i18n.tui.inputHelp] })] })) : mode === 'set-effort' ? (_jsxs(Box, { flexDirection: "column", borderStyle: theme.borders.modal, borderColor: theme.colors.borderActive, paddingX: 2, paddingY: 1, children: [_jsx(Text, { bold: true, color: theme.colors.accent, children: i18n.tui.effort?.set || 'Set effort' }), _jsx(Text, { color: theme.colors.textMuted, children: i18n.tui.effort?.setHelp || 'j/k: select, Enter: confirm, Esc: cancel' }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: EFFORT_OPTIONS.map((option, index) => (_jsxs(Text, { color: index === effortSelectIndex ? theme.colors.textSelected : theme.colors.text, bold: index === effortSelectIndex, children: [index === effortSelectIndex ? theme.style.selectedPrefix : theme.style.unselectedPrefix, option.label] }, option.label))) })] })) : (_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: [
755
863
  { key: 'i', label: i18n.tui.keyBar.comment },
756
864
  { key: 'd', label: i18n.tui.keyBar.delete },
757
865
  { key: 'P', label: i18n.tui.keyBar.project },
@@ -1,6 +1,11 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
3
  import { useTheme } from '../theme/index.js';
4
+ const EFFORT_LABELS = {
5
+ small: 'S',
6
+ medium: 'M',
7
+ large: 'L',
8
+ };
4
9
  // Round border characters (DQ style)
5
10
  const BORDER = {
6
11
  topLeft: '╭',
@@ -51,13 +56,13 @@ export function KanbanColumn({ title, tasks, isActive, selectedTaskIndex, column
51
56
  return (_jsxs(Box, { flexDirection: "column", flexGrow: 1, flexBasis: 0, marginRight: columnIndex < 2 ? 1 : 0, children: [_jsxs(Box, { children: [_jsx(Text, { color: color, children: BORDER.topLeft }), _jsxs(Text, { color: color, children: [BORDER.horizontal.repeat(leftDashes), " "] }), _jsx(Text, { color: isActive ? theme.colors.accent : theme.colors.textMuted, bold: isActive, children: titleText }), _jsxs(Text, { color: color, children: [" ", BORDER.horizontal.repeat(rightDashes)] }), _jsx(Text, { color: color, children: BORDER.topRight }), showShadow && _jsx(Text, { children: " " })] }), _jsxs(Box, { flexDirection: "column", minHeight: 8, children: [tasks.length === 0 ? (_jsxs(Box, { children: [_jsx(Text, { color: color, children: BORDER.vertical }), _jsx(Box, { paddingX: 1, flexGrow: 1, children: _jsx(Text, { color: theme.colors.textMuted, italic: true, children: "-" }) }), _jsx(Text, { color: color, children: BORDER.vertical }), showShadow && _jsx(Text, { color: shadowColor, children: SHADOW })] })) : (tasks.map((task, index) => {
52
57
  const isSelected = isActive && index === selectedTaskIndex;
53
58
  const shortId = task.id.slice(0, 6);
54
- return (_jsxs(Box, { children: [_jsx(Text, { color: color, children: BORDER.vertical }), _jsx(Box, { paddingX: 1, flexGrow: 1, children: _jsxs(Text, { color: isSelected ? theme.colors.textSelected : theme.colors.text, bold: isSelected, children: [isSelected ? theme.style.selectedPrefix : theme.style.unselectedPrefix, "[", shortId, "] ", task.title] }) }), _jsx(Text, { color: color, children: BORDER.vertical }), showShadow && _jsx(Text, { color: shadowColor, children: SHADOW })] }, task.id));
59
+ return (_jsxs(Box, { children: [_jsx(Text, { color: color, children: BORDER.vertical }), _jsx(Box, { paddingX: 1, flexGrow: 1, children: _jsxs(Text, { color: isSelected ? theme.colors.textSelected : theme.colors.text, bold: isSelected, children: [isSelected ? theme.style.selectedPrefix : theme.style.unselectedPrefix, task.isFocused ? '★ ' : '', task.effort ? `[${EFFORT_LABELS[task.effort]}] ` : '', "[", shortId, "] ", task.title] }) }), _jsx(Text, { color: color, children: BORDER.vertical }), showShadow && _jsx(Text, { color: shadowColor, children: SHADOW })] }, task.id));
55
60
  })), Array.from({ length: Math.max(0, 8 - Math.max(1, tasks.length)) }).map((_, i) => (_jsxs(Box, { children: [_jsx(Text, { color: color, children: BORDER.vertical }), _jsx(Box, { paddingX: 1, flexGrow: 1, children: _jsx(Text, { children: " " }) }), _jsx(Text, { color: color, children: BORDER.vertical }), showShadow && _jsx(Text, { color: shadowColor, children: SHADOW })] }, `empty-${i}`)))] }), _jsxs(Box, { children: [_jsx(Text, { color: color, children: BORDER.bottomLeft }), _jsx(Text, { color: color, children: BORDER.horizontal.repeat(innerWidth) }), _jsx(Text, { color: color, children: BORDER.bottomRight }), showShadow && _jsx(Text, { color: shadowColor, children: SHADOW })] }), showShadow && (_jsx(Box, { children: _jsxs(Text, { color: shadowColor, children: [" ", SHADOW.repeat(innerWidth + 2)] }) }))] }));
56
61
  }
57
62
  // Default style
58
63
  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) => {
59
64
  const isSelected = isActive && index === selectedTaskIndex;
60
65
  const shortId = task.id.slice(0, 6);
61
- 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));
66
+ return (_jsx(Box, { children: _jsxs(Text, { color: isSelected ? theme.colors.textSelected : theme.colors.text, bold: isSelected, children: [isSelected ? theme.style.selectedPrefix : theme.style.unselectedPrefix, task.isFocused ? '★ ' : '', task.effort ? `[${EFFORT_LABELS[task.effort]}] ` : '', "[", shortId, "] ", task.title] }) }, task.id));
62
67
  })) })] }));
63
68
  }