floq 1.7.0 → 1.9.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,109 @@
1
+ import { getCalendarSources, getLocale } from '../config.js';
2
+ import { fetchCalendarEvents, getEventsForDate, formatEventTime, isEventOngoing } from '../calendar/index.js';
3
+ const WEEKDAYS_EN = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
4
+ const WEEKDAYS_JA = ['日', '月', '火', '水', '木', '金', '土'];
5
+ function resolveRange(period, daysOption) {
6
+ const today = new Date();
7
+ const startOfToday = new Date(today.getFullYear(), today.getMonth(), today.getDate());
8
+ if (daysOption) {
9
+ const days = parseInt(daysOption, 10);
10
+ if (isNaN(days) || days < 1) {
11
+ console.error('Error: --days must be a positive integer');
12
+ process.exit(1);
13
+ }
14
+ return { start: startOfToday, days };
15
+ }
16
+ switch (period) {
17
+ case undefined:
18
+ case 'today':
19
+ return { start: startOfToday, days: 1 };
20
+ case 'tomorrow': {
21
+ const tomorrow = new Date(startOfToday);
22
+ tomorrow.setDate(tomorrow.getDate() + 1);
23
+ return { start: tomorrow, days: 1 };
24
+ }
25
+ case 'week':
26
+ return { start: startOfToday, days: 7 };
27
+ default:
28
+ console.error(`Error: Unknown period "${period}". Use: today, tomorrow, week`);
29
+ process.exit(1);
30
+ }
31
+ }
32
+ function formatDateHeader(date, locale) {
33
+ const today = new Date();
34
+ const startOfToday = new Date(today.getFullYear(), today.getMonth(), today.getDate());
35
+ const diff = Math.round((date.getTime() - startOfToday.getTime()) / (1000 * 60 * 60 * 24));
36
+ const weekdays = locale === 'ja' ? WEEKDAYS_JA : WEEKDAYS_EN;
37
+ const dateStr = locale === 'ja'
38
+ ? `${date.getMonth() + 1}/${date.getDate()}(${weekdays[date.getDay()]})`
39
+ : `${weekdays[date.getDay()]}, ${date.toLocaleString('en', { month: 'short' })} ${date.getDate()}`;
40
+ if (diff === 0)
41
+ return `${dateStr} ${locale === 'ja' ? '今日' : '(Today)'}`;
42
+ if (diff === 1)
43
+ return `${dateStr} ${locale === 'ja' ? '明日' : '(Tomorrow)'}`;
44
+ return dateStr;
45
+ }
46
+ /**
47
+ * Show schedule from all registered calendars
48
+ */
49
+ export async function showSchedule(period, options = {}) {
50
+ const sources = getCalendarSources();
51
+ const locale = getLocale();
52
+ if (sources.length === 0) {
53
+ console.log(locale === 'ja' ? 'カレンダーが設定されていません。' : 'No calendar configured.');
54
+ console.log('');
55
+ console.log(' floq calendar add <url> [-n name] (iCal URL)');
56
+ console.log(' floq calendar login && floq calendar select (Google OAuth)');
57
+ return;
58
+ }
59
+ const range = resolveRange(period, options.days);
60
+ await fetchCalendarEvents();
61
+ const showCalendarName = sources.filter(s => s.enabled !== false).length > 1;
62
+ const now = new Date();
63
+ const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate());
64
+ const baseOffset = Math.round((range.start.getTime() - startOfToday.getTime()) / (1000 * 60 * 60 * 24));
65
+ // Events are only fetched through the end of next month — warn if the range extends past it
66
+ const fetchWindowEnd = new Date(now.getFullYear(), now.getMonth() + 2, 0, 23, 59, 59);
67
+ const rangeEnd = new Date(range.start);
68
+ rangeEnd.setDate(rangeEnd.getDate() + range.days - 1);
69
+ if (rangeEnd > fetchWindowEnd) {
70
+ console.log(locale === 'ja'
71
+ ? `注意: 来月末より先の予定は表示されません`
72
+ : 'Note: events beyond the end of next month are not shown');
73
+ console.log('');
74
+ }
75
+ let totalShown = 0;
76
+ for (let i = 0; i < range.days; i++) {
77
+ const dayStart = new Date(range.start);
78
+ dayStart.setDate(dayStart.getDate() + i);
79
+ const dayEvents = getEventsForDate(baseOffset + i);
80
+ // For multi-day views, skip empty days
81
+ if (dayEvents.length === 0 && range.days > 1) {
82
+ continue;
83
+ }
84
+ if (totalShown > 0 || i > 0) {
85
+ console.log('');
86
+ }
87
+ console.log(formatDateHeader(dayStart, locale));
88
+ console.log('-'.repeat(40));
89
+ if (dayEvents.length === 0) {
90
+ console.log(locale === 'ja' ? ' 予定はありません' : ' No events');
91
+ continue;
92
+ }
93
+ for (const event of dayEvents) {
94
+ const timeStr = event.allDay
95
+ ? (locale === 'ja' ? '終日 ' : 'All day ')
96
+ : `${formatEventTime(event.start)}-${formatEventTime(event.end)}`;
97
+ const ongoing = isEventOngoing(event) ? ' ◀' : '';
98
+ const calLabel = showCalendarName && event.calendarName ? ` [${event.calendarName}]` : '';
99
+ console.log(` ${timeStr} ${event.title}${calLabel}${ongoing}`);
100
+ if (event.location) {
101
+ console.log(` 📍 ${event.location}`);
102
+ }
103
+ totalShown++;
104
+ }
105
+ }
106
+ if (range.days > 1 && totalShown === 0) {
107
+ console.log(locale === 'ja' ? '期間内に予定はありません' : 'No events in this period');
108
+ }
109
+ }
package/dist/config.d.ts CHANGED
@@ -21,12 +21,22 @@ export interface CalendarOAuthConfig {
21
21
  calendarId: string;
22
22
  calendarName: string;
23
23
  }
24
+ export interface CalendarSource {
25
+ id: string;
26
+ name: string;
27
+ type: 'ical' | 'oauth';
28
+ url?: string;
29
+ calendarId?: string;
30
+ enabled?: boolean;
31
+ }
24
32
  export interface CalendarConfig {
33
+ enabled?: boolean;
34
+ calendars?: CalendarSource[];
35
+ googleOAuth?: GoogleOAuthClient;
36
+ oauthTokens?: CalendarOAuthTokens;
25
37
  url?: string;
26
38
  name?: string;
27
- enabled?: boolean;
28
39
  type?: 'ical' | 'oauth';
29
- googleOAuth?: GoogleOAuthClient;
30
40
  oauth?: CalendarOAuthConfig;
31
41
  }
32
42
  export type DateFormat = 'auto' | 'ddd, MMM D' | 'MM/DD(ddd)' | 'YYYY-MM-DD' | 'MM-DD' | 'DD/MM' | 'none';
@@ -74,12 +84,16 @@ export declare function getDateFormat(): DateFormat;
74
84
  export declare function setDateFormat(format: DateFormat): void;
75
85
  export declare function getCalendarConfig(): CalendarConfig | undefined;
76
86
  export declare function setCalendarConfig(config: CalendarConfig | undefined): void;
87
+ export declare function getCalendarSources(): CalendarSource[];
88
+ export declare function addCalendarSource(source: Omit<CalendarSource, 'id'>): CalendarSource;
89
+ export declare function removeCalendarSource(id: string): boolean;
90
+ export declare function updateCalendarSource(id: string, updates: Partial<Omit<CalendarSource, 'id'>>): boolean;
91
+ export declare function isCalendarSourceUsable(source: CalendarSource, config?: CalendarConfig): boolean;
77
92
  export declare function isCalendarEnabled(): boolean;
78
93
  export declare function setCalendarEnabled(enabled: boolean): void;
79
94
  export declare function getGoogleOAuthClient(): GoogleOAuthClient | undefined;
80
95
  export declare function setGoogleOAuthClient(client: GoogleOAuthClient): void;
81
- export declare function getCalendarOAuthConfig(): CalendarOAuthConfig | undefined;
82
- export declare function setCalendarOAuthConfig(oauth: CalendarOAuthConfig | undefined): void;
83
- export declare function getCalendarType(): 'ical' | 'oauth' | undefined;
96
+ export declare function getCalendarOAuthTokens(): CalendarOAuthTokens | undefined;
97
+ export declare function setCalendarOAuthTokens(tokens: CalendarOAuthTokens | undefined): void;
84
98
  export declare function getInsightsWeeks(): number;
85
99
  export declare function setInsightsWeeks(weeks: number): void;
package/dist/config.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } from 'fs';
2
2
  import { dirname, join, isAbsolute } from 'path';
3
+ import { randomUUID } from 'crypto';
3
4
  import { CONFIG_FILE, DATA_DIR } from './paths.js';
4
5
  // Migrate legacy DB file names (including related metadata files)
5
6
  function migrateDbFiles() {
@@ -183,25 +184,109 @@ export function getDateFormat() {
183
184
  export function setDateFormat(format) {
184
185
  saveConfig({ dateFormat: format });
185
186
  }
187
+ function generateCalendarId() {
188
+ return randomUUID().slice(0, 8);
189
+ }
190
+ // Migrate legacy single-calendar config to the multi-calendar format
191
+ function migrateCalendarConfig(calendar) {
192
+ const hasLegacyFields = calendar.url !== undefined ||
193
+ calendar.name !== undefined ||
194
+ calendar.type !== undefined ||
195
+ calendar.oauth !== undefined;
196
+ if (!hasLegacyFields)
197
+ return null;
198
+ const calendars = [...(calendar.calendars || [])];
199
+ let oauthTokens = calendar.oauthTokens;
200
+ if (calendar.url && !calendars.some(c => c.url === calendar.url)) {
201
+ calendars.push({
202
+ id: generateCalendarId(),
203
+ name: calendar.name || 'Calendar',
204
+ type: 'ical',
205
+ url: calendar.url,
206
+ });
207
+ }
208
+ if (calendar.oauth) {
209
+ oauthTokens = oauthTokens || calendar.oauth.tokens;
210
+ if (calendar.oauth.calendarId && !calendars.some(c => c.calendarId === calendar.oauth?.calendarId)) {
211
+ calendars.push({
212
+ id: generateCalendarId(),
213
+ name: calendar.oauth.calendarName || 'Google Calendar',
214
+ type: 'oauth',
215
+ calendarId: calendar.oauth.calendarId,
216
+ });
217
+ }
218
+ }
219
+ return {
220
+ enabled: calendar.enabled,
221
+ calendars,
222
+ googleOAuth: calendar.googleOAuth,
223
+ oauthTokens,
224
+ };
225
+ }
186
226
  export function getCalendarConfig() {
187
- return loadConfig().calendar;
227
+ const calendar = loadConfig().calendar;
228
+ if (!calendar)
229
+ return undefined;
230
+ const migrated = migrateCalendarConfig(calendar);
231
+ if (migrated) {
232
+ saveConfig({ calendar: migrated });
233
+ return migrated;
234
+ }
235
+ return calendar;
188
236
  }
189
237
  export function setCalendarConfig(config) {
190
238
  saveConfig({ calendar: config });
191
239
  }
240
+ export function getCalendarSources() {
241
+ return getCalendarConfig()?.calendars || [];
242
+ }
243
+ export function addCalendarSource(source) {
244
+ const calendar = getCalendarConfig() || {};
245
+ const newSource = { id: generateCalendarId(), ...source };
246
+ setCalendarConfig({
247
+ ...calendar,
248
+ calendars: [...(calendar.calendars || []), newSource],
249
+ enabled: calendar.enabled === false ? calendar.enabled : true,
250
+ });
251
+ return newSource;
252
+ }
253
+ export function removeCalendarSource(id) {
254
+ const calendar = getCalendarConfig();
255
+ if (!calendar)
256
+ return false;
257
+ const calendars = calendar.calendars || [];
258
+ const newCalendars = calendars.filter(c => c.id !== id);
259
+ if (newCalendars.length === calendars.length)
260
+ return false;
261
+ setCalendarConfig({ ...calendar, calendars: newCalendars });
262
+ return true;
263
+ }
264
+ export function updateCalendarSource(id, updates) {
265
+ const calendar = getCalendarConfig();
266
+ if (!calendar)
267
+ return false;
268
+ const calendars = calendar.calendars || [];
269
+ const index = calendars.findIndex(c => c.id === id);
270
+ if (index === -1)
271
+ return false;
272
+ const newCalendars = [...calendars];
273
+ newCalendars[index] = { ...newCalendars[index], ...updates };
274
+ setCalendarConfig({ ...calendar, calendars: newCalendars });
275
+ return true;
276
+ }
277
+ // True when a calendar source has everything it needs to fetch events
278
+ export function isCalendarSourceUsable(source, config) {
279
+ const calendar = config ?? getCalendarConfig();
280
+ if (source.type === 'ical') {
281
+ return !!source.url;
282
+ }
283
+ return !!source.calendarId && !!calendar?.oauthTokens;
284
+ }
192
285
  export function isCalendarEnabled() {
193
286
  const calendar = getCalendarConfig();
194
287
  if (!calendar || calendar.enabled === false)
195
288
  return false;
196
- // OAuth mode
197
- if (calendar.type === 'oauth' && calendar.oauth) {
198
- return true;
199
- }
200
- // iCal mode
201
- if (calendar.url && calendar.url !== '') {
202
- return true;
203
- }
204
- return false;
289
+ return (calendar.calendars || []).some(c => c.enabled !== false && isCalendarSourceUsable(c, calendar));
205
290
  }
206
291
  export function setCalendarEnabled(enabled) {
207
292
  const calendar = getCalendarConfig();
@@ -224,26 +309,19 @@ export function setGoogleOAuthClient(client) {
224
309
  const calendar = getCalendarConfig() || {};
225
310
  setCalendarConfig({ ...calendar, googleOAuth: client });
226
311
  }
227
- export function getCalendarOAuthConfig() {
228
- return getCalendarConfig()?.oauth;
312
+ export function getCalendarOAuthTokens() {
313
+ return getCalendarConfig()?.oauthTokens;
229
314
  }
230
- export function setCalendarOAuthConfig(oauth) {
315
+ export function setCalendarOAuthTokens(tokens) {
231
316
  const calendar = getCalendarConfig() || {};
232
- if (oauth) {
233
- setCalendarConfig({ ...calendar, type: 'oauth', oauth, enabled: true });
317
+ if (tokens) {
318
+ setCalendarConfig({ ...calendar, oauthTokens: tokens });
234
319
  }
235
320
  else {
236
- // Clear OAuth config
237
- const { oauth: _removed, ...rest } = calendar;
321
+ const { oauthTokens: _removed, ...rest } = calendar;
238
322
  setCalendarConfig(rest);
239
323
  }
240
324
  }
241
- export function getCalendarType() {
242
- const calendar = getCalendarConfig();
243
- if (!calendar)
244
- return undefined;
245
- return calendar.type || (calendar.url ? 'ical' : undefined);
246
- }
247
325
  const DEFAULT_INSIGHTS_WEEKS = 2;
248
326
  export function getInsightsWeeks() {
249
327
  const weeks = loadConfig().insightsWeeks;
@@ -0,0 +1,20 @@
1
+ import type { Task, Comment } from './schema.js';
2
+ /**
3
+ * How to handle child tasks when deleting a project.
4
+ * - `cascade`: delete the project together with all its child tasks (and their comments)
5
+ * - `keep`: delete only the project, moving its child tasks back to Inbox (unlinked)
6
+ */
7
+ export type ProjectDeleteMode = 'cascade' | 'keep';
8
+ export interface ProjectDeleteResult {
9
+ /** Child tasks that were deleted (only populated for `cascade`). */
10
+ deletedTasks: Task[];
11
+ /** Child tasks moved to Inbox, in their original state (only populated for `keep`). */
12
+ movedTasks: Task[];
13
+ /** Comments that were deleted (project's own comments, plus child comments in `cascade`). */
14
+ deletedComments: Comment[];
15
+ }
16
+ /**
17
+ * Delete a project and handle its child tasks according to `mode`.
18
+ * Returns the prior state so the operation can be undone.
19
+ */
20
+ export declare function deleteProject(project: Task, mode: ProjectDeleteMode): Promise<ProjectDeleteResult>;
@@ -0,0 +1,49 @@
1
+ import { eq } from 'drizzle-orm';
2
+ import { getDb, schema } from './index.js';
3
+ /**
4
+ * Delete a project and handle its child tasks according to `mode`.
5
+ * Returns the prior state so the operation can be undone.
6
+ */
7
+ export async function deleteProject(project, mode) {
8
+ const db = getDb();
9
+ const children = await db
10
+ .select()
11
+ .from(schema.tasks)
12
+ .where(eq(schema.tasks.parentId, project.id));
13
+ const deletedTasks = [];
14
+ const movedTasks = [];
15
+ let deletedComments = [];
16
+ // Always remove the project's own comments.
17
+ const projectComments = await db
18
+ .select()
19
+ .from(schema.comments)
20
+ .where(eq(schema.comments.taskId, project.id));
21
+ deletedComments = deletedComments.concat(projectComments);
22
+ await db.delete(schema.comments).where(eq(schema.comments.taskId, project.id));
23
+ if (mode === 'cascade') {
24
+ for (const child of children) {
25
+ const childComments = await db
26
+ .select()
27
+ .from(schema.comments)
28
+ .where(eq(schema.comments.taskId, child.id));
29
+ deletedComments = deletedComments.concat(childComments);
30
+ await db.delete(schema.comments).where(eq(schema.comments.taskId, child.id));
31
+ await db.delete(schema.tasks).where(eq(schema.tasks.id, child.id));
32
+ deletedTasks.push(child);
33
+ }
34
+ }
35
+ else {
36
+ // keep: unlink children and move them back to Inbox.
37
+ const now = new Date();
38
+ for (const child of children) {
39
+ movedTasks.push(child);
40
+ await db
41
+ .update(schema.tasks)
42
+ .set({ parentId: null, status: 'inbox', updatedAt: now })
43
+ .where(eq(schema.tasks.id, child.id));
44
+ }
45
+ }
46
+ // Finally remove the project itself.
47
+ await db.delete(schema.tasks).where(eq(schema.tasks.id, project.id));
48
+ return { deletedTasks, movedTasks, deletedComments };
49
+ }
package/dist/i18n/en.d.ts CHANGED
@@ -51,6 +51,12 @@ export declare const en: {
51
51
  notFound: string;
52
52
  multipleMatch: string;
53
53
  completed: string;
54
+ deleted: string;
55
+ deletedWithTasks: string;
56
+ tasksMovedToInbox: string;
57
+ deletePrompt: string;
58
+ deleteChildrenPrompt: string;
59
+ deleteCancelled: string;
54
60
  noProjects: string;
55
61
  description: string;
56
62
  statusLabel: string;
@@ -247,6 +253,11 @@ export declare const en: {
247
253
  deleteConfirm: string;
248
254
  deleted: string;
249
255
  deleteCancelled: string;
256
+ deleteProjectConfirm: string;
257
+ deleteProjectChildren: string;
258
+ deletedProject: string;
259
+ deletedProjectWithTasks: string;
260
+ projectTasksToInbox: string;
250
261
  undone: string;
251
262
  redone: string;
252
263
  nothingToUndo: string;
@@ -675,6 +686,11 @@ export type TuiTranslations = {
675
686
  deleteConfirm: string;
676
687
  deleted: string;
677
688
  deleteCancelled: string;
689
+ deleteProjectConfirm?: string;
690
+ deleteProjectChildren?: string;
691
+ deletedProject?: string;
692
+ deletedProjectWithTasks?: string;
693
+ projectTasksToInbox?: string;
678
694
  undone: string;
679
695
  redone: string;
680
696
  nothingToUndo: string;
package/dist/i18n/en.js CHANGED
@@ -55,6 +55,12 @@ export const en = {
55
55
  notFound: 'Project not found: {id}',
56
56
  multipleMatch: 'Multiple projects match "{id}". Please be more specific.',
57
57
  completed: 'Completed project: "{name}"',
58
+ deleted: 'Deleted project: "{name}"',
59
+ deletedWithTasks: 'Deleted project "{name}" and {count} task(s)',
60
+ tasksMovedToInbox: 'Moved {count} task(s) to Inbox',
61
+ deletePrompt: 'Delete project "{name}"?',
62
+ deleteChildrenPrompt: 'Project "{name}" has {count} task(s). a=delete all k=keep tasks (→Inbox) n=cancel',
63
+ deleteCancelled: 'Cancelled.',
58
64
  noProjects: 'No projects',
59
65
  description: 'Description: {description}',
60
66
  statusLabel: 'Status: {status}',
@@ -260,6 +266,11 @@ export const en = {
260
266
  deleteConfirm: 'Delete "{title}"? (y/n)',
261
267
  deleted: 'Deleted: "{title}"',
262
268
  deleteCancelled: 'Delete cancelled',
269
+ deleteProjectConfirm: 'Delete project "{title}"? (y/n)',
270
+ deleteProjectChildren: 'Project "{title}" has {count} task(s). t=delete all i=keep tasks (→Inbox) n=cancel',
271
+ deletedProject: 'Deleted project: "{title}"',
272
+ deletedProjectWithTasks: 'Deleted project "{title}" and {count} task(s)',
273
+ projectTasksToInbox: 'Deleted project "{title}", moved {count} task(s) to Inbox',
263
274
  // Undo/Redo
264
275
  undone: 'Undone: {action}',
265
276
  redone: 'Redone: {action}',
package/dist/i18n/ja.js CHANGED
@@ -55,6 +55,12 @@ export const ja = {
55
55
  notFound: 'プロジェクトが見つかりません: {id}',
56
56
  multipleMatch: '「{id}」に一致するプロジェクトが複数あります。より具体的に指定してください。',
57
57
  completed: 'プロジェクトを完了しました: 「{name}」',
58
+ deleted: 'プロジェクトを削除しました: 「{name}」',
59
+ deletedWithTasks: 'プロジェクト「{name}」とタスク{count}件を削除しました',
60
+ tasksMovedToInbox: 'タスク{count}件をInboxへ移動しました',
61
+ deletePrompt: 'プロジェクト「{name}」を削除しますか?',
62
+ deleteChildrenPrompt: 'プロジェクト「{name}」にはタスクが{count}件あります。 a=すべて削除 k=タスクを残す(→Inbox) n=キャンセル',
63
+ deleteCancelled: 'キャンセルしました。',
58
64
  noProjects: 'プロジェクトなし',
59
65
  description: '説明: {description}',
60
66
  statusLabel: 'ステータス: {status}',
@@ -260,6 +266,11 @@ export const ja = {
260
266
  deleteConfirm: '「{title}」を削除しますか? (y/n)',
261
267
  deleted: '削除しました: 「{title}」',
262
268
  deleteCancelled: '削除をキャンセルしました',
269
+ deleteProjectConfirm: 'プロジェクト「{title}」を削除しますか? (y/n)',
270
+ deleteProjectChildren: 'プロジェクト「{title}」にはタスクが{count}件あります。 t=すべて削除 i=タスクを残す(→Inbox) n=キャンセル',
271
+ deletedProject: 'プロジェクトを削除しました: 「{title}」',
272
+ deletedProjectWithTasks: 'プロジェクト「{title}」とタスク{count}件を削除しました',
273
+ projectTasksToInbox: 'プロジェクト「{title}」を削除し、タスク{count}件をInboxへ移動しました',
263
274
  // Undo/Redo
264
275
  undone: '元に戻しました: {action}',
265
276
  redone: 'やり直しました: {action}',
package/dist/ui/App.js CHANGED
@@ -30,7 +30,7 @@ import { KanbanMario } from './components/KanbanMario.js';
30
30
  import { GtdDQ } from './components/GtdDQ.js';
31
31
  import { GtdMario } from './components/GtdMario.js';
32
32
  import { VERSION } from '../version.js';
33
- import { HistoryProvider, useHistory, CreateTaskCommand, DeleteTaskCommand, MoveTaskCommand, LinkTaskCommand, ConvertToProjectCommand, CreateCommentCommand, DeleteCommentCommand, SetContextCommand, SetFocusCommand, SetEffortCommand, } from './history/index.js';
33
+ import { HistoryProvider, useHistory, CreateTaskCommand, DeleteTaskCommand, DeleteProjectCommand, MoveTaskCommand, LinkTaskCommand, ConvertToProjectCommand, CreateCommentCommand, DeleteCommentCommand, SetContextCommand, SetFocusCommand, SetEffortCommand, } from './history/index.js';
34
34
  const TABS = ['inbox', 'next', 'waiting', 'someday', 'projects', 'done'];
35
35
  export function App() {
36
36
  const [themeName, setThemeNameState] = useState(getThemeName);
@@ -102,6 +102,8 @@ function AppContent({ onOpenSettings }) {
102
102
  const [selectedCommentIndex, setSelectedCommentIndex] = useState(0);
103
103
  const [taskToWaiting, setTaskToWaiting] = useState(null);
104
104
  const [taskToDelete, setTaskToDelete] = useState(null);
105
+ const [projectToDelete, setProjectToDelete] = useState(null);
106
+ const [projectChildCount, setProjectChildCount] = useState(0);
105
107
  const [projectProgress, setProjectProgress] = useState({});
106
108
  // Search state
107
109
  const [searchQuery, setSearchQuery] = useState('');
@@ -517,6 +519,19 @@ function AppContent({ onOpenSettings }) {
517
519
  setMessage(fmt(i18n.tui.deleted || 'Deleted: "{title}"', { title: task.title }));
518
520
  await loadTasks();
519
521
  }, [i18n.tui.deleted, loadTasks, history]);
522
+ const deleteProjectAction = useCallback(async (project, mode, childCount) => {
523
+ const message = mode === 'cascade'
524
+ ? (childCount > 0
525
+ ? fmt(i18n.tui.deletedProjectWithTasks || 'Deleted project "{title}" and {count} task(s)', { title: project.title, count: childCount })
526
+ : fmt(i18n.tui.deletedProject || 'Deleted project: "{title}"', { title: project.title }))
527
+ : (childCount > 0
528
+ ? fmt(i18n.tui.projectTasksToInbox || 'Deleted project "{title}", moved {count} task(s) to Inbox', { title: project.title, count: childCount })
529
+ : fmt(i18n.tui.deletedProject || 'Deleted project: "{title}"', { title: project.title }));
530
+ const command = new DeleteProjectCommand({ project, mode, description: message });
531
+ await history.execute(command);
532
+ setMessage(message);
533
+ await loadTasks();
534
+ }, [i18n.tui.deletedProject, i18n.tui.deletedProjectWithTasks, i18n.tui.projectTasksToInbox, loadTasks, history]);
520
535
  const getTabLabel = (tab) => {
521
536
  switch (tab) {
522
537
  case 'inbox':
@@ -576,6 +591,51 @@ function AppContent({ onOpenSettings }) {
576
591
  // Ignore other keys in confirm mode
577
592
  return;
578
593
  }
594
+ // Handle confirm-delete-project mode
595
+ if (mode === 'confirm-delete-project' && projectToDelete) {
596
+ const cancel = () => {
597
+ setMessage(i18n.tui.deleteCancelled || 'Delete cancelled');
598
+ setProjectToDelete(null);
599
+ setMode('normal');
600
+ };
601
+ const runDelete = (deleteMode) => {
602
+ const project = projectToDelete;
603
+ const count = projectChildCount;
604
+ deleteProjectAction(project, deleteMode, count).then(() => {
605
+ if (selectedTaskIndex >= currentTasks.length - 1) {
606
+ setSelectedTaskIndex(Math.max(0, selectedTaskIndex - 1));
607
+ }
608
+ });
609
+ setProjectToDelete(null);
610
+ setMode('normal');
611
+ };
612
+ if (projectChildCount > 0) {
613
+ if (input === 't' || input === 'T') {
614
+ runDelete('cascade');
615
+ return;
616
+ }
617
+ if (input === 'i' || input === 'I') {
618
+ runDelete('keep');
619
+ return;
620
+ }
621
+ if (input === 'n' || input === 'N' || key.escape) {
622
+ cancel();
623
+ return;
624
+ }
625
+ }
626
+ else {
627
+ if (input === 'y' || input === 'Y') {
628
+ runDelete('cascade');
629
+ return;
630
+ }
631
+ if (input === 'n' || input === 'N' || key.escape) {
632
+ cancel();
633
+ return;
634
+ }
635
+ }
636
+ // Ignore other keys in confirm mode
637
+ return;
638
+ }
579
639
  // Handle add mode
580
640
  if (mode === 'add' || mode === 'add-to-project' || mode === 'add-comment' || mode === 'move-to-waiting') {
581
641
  if (key.escape) {
@@ -1077,6 +1137,20 @@ function AppContent({ onOpenSettings }) {
1077
1137
  });
1078
1138
  return;
1079
1139
  }
1140
+ // Delete project (D key on projects tab - with confirmation)
1141
+ if (input === 'D' && currentTasks.length > 0 && currentTab === 'projects') {
1142
+ const project = currentTasks[selectedTaskIndex];
1143
+ const db = getDb();
1144
+ db.select()
1145
+ .from(schema.tasks)
1146
+ .where(eq(schema.tasks.parentId, project.id))
1147
+ .then((children) => {
1148
+ setProjectChildCount(children.length);
1149
+ setProjectToDelete(project);
1150
+ setMode('confirm-delete-project');
1151
+ });
1152
+ return;
1153
+ }
1080
1154
  // Delete task (D key - with confirmation)
1081
1155
  if (input === 'D' && currentTasks.length > 0 && currentTab !== 'projects') {
1082
1156
  const task = currentTasks[selectedTaskIndex];
@@ -1224,7 +1298,9 @@ function AppContent({ onOpenSettings }) {
1224
1298
  const isActive = (ctx === 'clear' && !currentContext) ||
1225
1299
  (ctx !== 'clear' && ctx !== 'new' && currentContext === ctx);
1226
1300
  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));
1227
- }) }), _jsx(Text, { color: theme.colors.textMuted, children: i18n.tui.context?.setContextHelp || 'j/k: select, Enter: confirm, Esc: cancel' })] })), 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))) })] })), 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 === 'search' && (_jsx(SearchBar, { value: searchQuery, onChange: handleSearchChange, onSubmit: handleInputSubmit })), mode === 'confirm-delete' && taskToDelete && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.colors.accent, bold: true, children: fmt(i18n.tui.deleteConfirm || 'Delete "{title}"? (y/n)', { title: taskToDelete.title }) }) })), message && mode === 'normal' && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.colors.textHighlight, children: message }) })), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [(mode === 'task-detail' || mode === 'add-comment') ? (theme.style.showFunctionKeys ? (_jsx(FunctionKeyBar, { keys: [
1301
+ }) }), _jsx(Text, { color: theme.colors.textMuted, children: i18n.tui.context?.setContextHelp || 'j/k: select, Enter: confirm, Esc: cancel' })] })), 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))) })] })), 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 === 'search' && (_jsx(SearchBar, { value: searchQuery, onChange: handleSearchChange, onSubmit: handleInputSubmit })), mode === 'confirm-delete' && taskToDelete && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.colors.accent, bold: true, children: fmt(i18n.tui.deleteConfirm || 'Delete "{title}"? (y/n)', { title: taskToDelete.title }) }) })), mode === 'confirm-delete-project' && projectToDelete && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.colors.accent, bold: true, children: projectChildCount > 0
1302
+ ? fmt(i18n.tui.deleteProjectChildren || 'Project "{title}" has {count} task(s). t=delete all i=keep tasks (→Inbox) n=cancel', { title: projectToDelete.title, count: projectChildCount })
1303
+ : fmt(i18n.tui.deleteProjectConfirm || 'Delete project "{title}"? (y/n)', { title: projectToDelete.title }) }) })), message && mode === 'normal' && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.colors.textHighlight, children: message }) })), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [(mode === 'task-detail' || mode === 'add-comment') ? (theme.style.showFunctionKeys ? (_jsx(FunctionKeyBar, { keys: [
1228
1304
  { key: 'i', label: i18n.tui.keyBar.comment },
1229
1305
  { key: 'd', label: i18n.tui.keyBar.delete },
1230
1306
  { key: 'P', label: i18n.tui.keyBar.project },
@@ -3,7 +3,7 @@ import { useState, useEffect, useMemo } from 'react';
3
3
  import { Box, Text, useInput } from 'ink';
4
4
  import { t } from '../../i18n/index.js';
5
5
  import { useTheme } from '../theme/index.js';
6
- import { isCalendarEnabled, getCalendarConfig, getCalendarType } from '../../config.js';
6
+ import { isCalendarEnabled, getCalendarSources } from '../../config.js';
7
7
  import { getCalendarEvents, getEventsForDate, formatEventTime, isEventOngoing, } from '../../calendar/index.js';
8
8
  const WEEKDAYS_EN = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
9
9
  const WEEKDAYS_JA = ['日', '月', '火', '水', '木', '金', '土'];
@@ -19,8 +19,7 @@ export function CalendarModal({ onClose }) {
19
19
  const [viewMonth, setViewMonth] = useState(new Date());
20
20
  const [mode, setMode] = useState('calendar');
21
21
  const calendarEnabled = isCalendarEnabled();
22
- const config = getCalendarConfig();
23
- const calendarType = getCalendarType();
22
+ const calendarSources = getCalendarSources().filter(s => s.enabled !== false);
24
23
  const isJapanese = i18n.tui.calendar?.yesterday === '昨日';
25
24
  const weekdays = isJapanese ? WEEKDAYS_JA : WEEKDAYS_EN;
26
25
  // Load all events on mount
@@ -211,10 +210,12 @@ export function CalendarModal({ onClose }) {
211
210
  }
212
211
  });
213
212
  const formatTitle = (title) => theme.style.headerUppercase ? title.toUpperCase() : title;
214
- // Get calendar name for header
215
- const calendarName = calendarType === 'oauth' && config?.oauth
216
- ? config.oauth.calendarName
217
- : config?.name || 'Calendar';
213
+ // Get calendar name for header (single calendar shows its name, multiple show a count)
214
+ const calendarName = calendarSources.length === 1
215
+ ? calendarSources[0].name
216
+ : calendarSources.length > 1
217
+ ? `Calendar (${calendarSources.length})`
218
+ : 'Calendar';
218
219
  // Format month header
219
220
  const monthNames = isJapanese
220
221
  ? ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月']