From 68a97f0c27203fca494a7e19138a80ea9a805278 Mon Sep 17 00:00:00 2001 From: Masato Fukushima Date: Sun, 15 Feb 2026 10:46:31 +0900 Subject: [PATCH 1/4] Optimize frontend data fetching with parallelization --- apps/web/src/app/ai-planning/page.tsx | 47 +++++++++++++ apps/web/src/app/scheduling/page.tsx | 70 ++++++++++++------- apps/web/src/app/settings/page.tsx | 33 ++++----- .../runner/manual-task-select-dialog.tsx | 20 +++++- apps/web/src/hooks/use-project-page-state.ts | 2 +- apps/web/src/hooks/use-runner.ts | 33 ++++++--- 6 files changed, 152 insertions(+), 53 deletions(-) diff --git a/apps/web/src/app/ai-planning/page.tsx b/apps/web/src/app/ai-planning/page.tsx index 03db56ff..41f876f1 100644 --- a/apps/web/src/app/ai-planning/page.tsx +++ b/apps/web/src/app/ai-planning/page.tsx @@ -118,6 +118,53 @@ export default function AIPlanningPage() { const [weeklyTaskDialogOpen, setWeeklyTaskDialogOpen] = useState(false); const [editingWeeklyTask, setEditingWeeklyTask] = useState(null); + const preloadAiPlanningData = useCallback(async () => { + if (hasApiKey !== true) return; + + setLoadingSchedules(true); + setLoadingWeeklyTasks(true); + + try { + const [schedulesResult, weeklyTasksResult] = await Promise.allSettled([ + weeklyScheduleApi.getAll(), + weeklyRecurringTasksApi.getAll(), + ]); + + if (schedulesResult.status === 'fulfilled') { + setSavedSchedules(schedulesResult.value); + } else { + toast({ + title: '保存済み週間スケジュールの取得に失敗しました', + description: schedulesResult.reason instanceof Error + ? schedulesResult.reason.message + : '不明なエラーが発生しました', + variant: 'destructive', + }); + } + + if (weeklyTasksResult.status === 'fulfilled') { + setWeeklyTasks(weeklyTasksResult.value); + } else { + toast({ + title: 'エラー', + description: weeklyTasksResult.reason instanceof Error + ? weeklyTasksResult.reason.message + : '週課の取得に失敗しました', + variant: 'destructive', + }); + } + } finally { + setLoadingSchedules(false); + setLoadingWeeklyTasks(false); + } + }, [hasApiKey, toast]); + + useEffect(() => { + if (!checkingApiKey && hasApiKey === true) { + void preloadAiPlanningData(); + } + }, [checkingApiKey, hasApiKey, preloadAiPlanningData]); + if (authLoading || !user) { return (
diff --git a/apps/web/src/app/scheduling/page.tsx b/apps/web/src/app/scheduling/page.tsx index 4f65c2ac..4ebf60ab 100644 --- a/apps/web/src/app/scheduling/page.tsx +++ b/apps/web/src/app/scheduling/page.tsx @@ -194,12 +194,11 @@ export default function SchedulingPage() { } else if (taskSource.type === 'all_tasks') { // Load tasks from all projects const allTasks: TaskInfo[] = []; - for (const project of projects) { - try { - const projectTasks = await tasksApi.getByProject(project.id); - const filtered = projectTasks - .filter(t => t.status !== 'completed' && t.status !== 'cancelled') - .map(t => ({ + const taskFetches = projects.map((project) => + tasksApi.getByProject(project.id).then((projectTasks) => + projectTasks + .filter((t) => t.status !== 'completed' && t.status !== 'cancelled') + .map((t) => ({ id: t.id, title: t.title, estimate_hours: Number(t.estimate_hours) || 1, @@ -208,31 +207,52 @@ export default function SchedulingPage() { due_date: t.due_date ?? undefined, goal_id: t.goal_id, project_id: project.id, - })); - allTasks.push(...(filtered as TaskInfo[])); - } catch (err) { - logger.error(`Failed to load tasks for project ${project.id}`, err instanceof Error ? err : new Error(String(err))); - } - } - - // Also load quick tasks (unclassified tasks) - try { - const quickTasks = await quickTasksApi.getAll(); - const filteredQuickTasks = quickTasks - .filter(qt => qt.status !== 'completed' && qt.status !== 'cancelled') - .map(qt => ({ - id: `quick_${qt.id}`, // Prefix to match backend convention - title: `📥 ${qt.title}`, // Mark as quick task + })) + ) + ); + + const quickTasksPromise = quickTasksApi.getAll().then((quickTasks) => + quickTasks + .filter((qt) => qt.status !== 'completed' && qt.status !== 'cancelled') + .map((qt) => ({ + id: `quick_${qt.id}`, // Prefix to match backend convention + title: `📥 ${qt.title}`, // Mark as quick task estimate_hours: Number(qt.estimate_hours) || 0.5, priority: qt.priority || 3, kind: qt.work_type || 'light_work', due_date: qt.due_date ?? undefined, goal_id: undefined, project_id: undefined, - })); - allTasks.push(...(filteredQuickTasks as TaskInfo[])); - } catch (err) { - logger.error('Failed to load quick tasks', err instanceof Error ? err : new Error(String(err))); + })) + ); + + const [projectTaskResults, quickTasksResult] = await Promise.all([ + Promise.allSettled(taskFetches), + quickTasksPromise + .then>((value) => ({ status: 'fulfilled', value })) + .catch>((reason) => ({ status: 'rejected', reason })), + ]); + + projectTaskResults.forEach((result, index) => { + if (result.status === 'fulfilled') { + allTasks.push(...(result.value as TaskInfo[])); + } else { + logger.error( + `Failed to load tasks for project ${projects[index]?.id}`, + result.reason instanceof Error ? result.reason : new Error(String(result.reason)) + ); + } + }); + + if (quickTasksResult.status === 'fulfilled') { + allTasks.push(...(quickTasksResult.value as TaskInfo[])); + } else { + logger.error( + 'Failed to load quick tasks', + quickTasksResult.reason instanceof Error + ? quickTasksResult.reason + : new Error(String(quickTasksResult.reason)) + ); } tasks = allTasks; diff --git a/apps/web/src/app/settings/page.tsx b/apps/web/src/app/settings/page.tsx index 84cdb58b..62af010f 100644 --- a/apps/web/src/app/settings/page.tsx +++ b/apps/web/src/app/settings/page.tsx @@ -69,6 +69,15 @@ export default function SettingsPage() { } } | null>(null) + const getAuthContext = async () => { + const [{ data: { user } }, { data: { session } }] = await Promise.all([ + supabase.auth.getUser(), + supabase.auth.getSession(), + ]) + + return { user, session } + } + useEffect(() => { // Create AbortController for cleanup abortControllerRef.current = new AbortController() @@ -134,8 +143,7 @@ export default function SettingsPage() { const fetchUserSettings = async () => { try { - const { data: { user } } = await supabase.auth.getUser() - const { data: { session } } = await supabase.auth.getSession() + const { user, session } = await getAuthContext() if (!user || !session?.access_token) { router.push("/login") @@ -187,8 +195,7 @@ export default function SettingsPage() { setSuccess("") try { - const { data: { user } } = await supabase.auth.getUser() - const { data: { session } } = await supabase.auth.getSession() + const { user, session } = await getAuthContext() if (!user || !session?.access_token) { router.push("/login") @@ -233,8 +240,7 @@ export default function SettingsPage() { setSuccess("") try { - const { data: { user } } = await supabase.auth.getUser() - const { data: { session } } = await supabase.auth.getSession() + const { user, session } = await getAuthContext() if (!user || !session?.access_token) { router.push("/login") @@ -278,8 +284,7 @@ export default function SettingsPage() { setSuccess("") try { - const { data: { user } } = await supabase.auth.getUser() - const { data: { session } } = await supabase.auth.getSession() + const { user, session } = await getAuthContext() if (!user || !session?.access_token) { router.push("/login") @@ -314,8 +319,7 @@ export default function SettingsPage() { setSuccess("") try { - const { data: { user } } = await supabase.auth.getUser() - const { data: { session } } = await supabase.auth.getSession() + const { user, session } = await getAuthContext() if (!user || !session?.access_token) { router.push("/login") @@ -352,8 +356,7 @@ export default function SettingsPage() { const fetchExportInfo = async () => { try { - const { data: { user } } = await supabase.auth.getUser() - const { data: { session } } = await supabase.auth.getSession() + const { user, session } = await getAuthContext() if (!user || !session?.access_token) { return @@ -384,8 +387,7 @@ export default function SettingsPage() { setSuccess("") try { - const { data: { user } } = await supabase.auth.getUser() - const { data: { session } } = await supabase.auth.getSession() + const { user, session } = await getAuthContext() if (!user || !session?.access_token) { router.push("/login") @@ -431,8 +433,7 @@ export default function SettingsPage() { setSuccess("") try { - const { data: { user } } = await supabase.auth.getUser() - const { data: { session } } = await supabase.auth.getSession() + const { user, session } = await getAuthContext() if (!user || !session?.access_token) { router.push("/login") diff --git a/apps/web/src/components/runner/manual-task-select-dialog.tsx b/apps/web/src/components/runner/manual-task-select-dialog.tsx index 3055db53..2d20c6c5 100644 --- a/apps/web/src/components/runner/manual-task-select-dialog.tsx +++ b/apps/web/src/components/runner/manual-task-select-dialog.tsx @@ -25,6 +25,7 @@ import { } from '@/components/ui/select'; import { Clock, Search, FolderOpen, AlertCircle } from 'lucide-react'; import { projectsApi, tasksApi } from '@/lib/api'; +import { log } from '@/lib/logger'; import type { Project } from '@/types/project'; interface ManualTaskSelectDialogProps { @@ -63,12 +64,25 @@ export function ManualTaskSelectDialog({ queryKey: ['tasks', 'manual-select', selectedProjectId, projects.map(p => p.id).join(',')], queryFn: async () => { if (selectedProjectId === 'all') { - // Fetch tasks from all projects in parallel using Promise.all + // Fetch tasks from all projects in parallel. + // Ignore failures for individual projects and return other projects' tasks. const taskPromises = projects.map((project) => tasksApi.getByProject(project.id, 0, 100) ); - const taskResults = await Promise.all(taskPromises); - return taskResults.flat(); + const taskResults = await Promise.allSettled(taskPromises); + return taskResults.flatMap((result, index) => { + if (result.status === 'fulfilled') { + return result.value; + } + + log.error( + `Failed to load tasks for project ${projects[index]?.id}`, + result.reason instanceof Error ? result.reason : new Error(String(result.reason)), + { component: 'ManualTaskSelectDialog' } + ); + + return []; + }); } else { return tasksApi.getByProject(selectedProjectId, 0, 100); } diff --git a/apps/web/src/hooks/use-project-page-state.ts b/apps/web/src/hooks/use-project-page-state.ts index c150e388..6bfea642 100644 --- a/apps/web/src/hooks/use-project-page-state.ts +++ b/apps/web/src/hooks/use-project-page-state.ts @@ -75,7 +75,7 @@ export function useProjectPageState(projectId: string): ProjectPageState { const { data: projectProgress } = useQuery({ queryKey: ['progress', 'project', projectId], queryFn: () => progressApi.getProject(projectId), - enabled: !!project, + enabled: !!projectId, }) const isInitializing = authLoading || !user diff --git a/apps/web/src/hooks/use-runner.ts b/apps/web/src/hooks/use-runner.ts index 5a54b15b..2197d8fb 100644 --- a/apps/web/src/hooks/use-runner.ts +++ b/apps/web/src/hooks/use-runner.ts @@ -21,6 +21,8 @@ import { useCountdown } from './use-countdown'; import { useNotifications } from './use-notifications'; import { schedulingApi, tasksApi, goalsApi, projectsApi, workSessionsApi } from '@/lib/api'; import { queryKeys } from '@/lib/query-keys'; +import type { Goal } from '@/types/goal'; +import type { Project } from '@/types/project'; import type { SessionDecision } from '@/types/work-session'; import type { RescheduleSuggestion } from '@/types/reschedule'; import type { @@ -87,17 +89,32 @@ export function useRunner(): UseRunnerReturn { try { const task = await tasksApi.getById(session.task_id); - let goal = null; - let project = null; + let goal: Goal | null = null; + let project: Project | null = null; if (task.goal_id) { - try { - goal = await goalsApi.getById(task.goal_id); - if (goal?.project_id) { - project = await projectsApi.getById(goal.project_id); + const cachedGoal = queryClient.getQueryData(queryKeys.goals.detail(task.goal_id)); + if (cachedGoal) { + goal = cachedGoal; + } else { + try { + goal = await goalsApi.getById(task.goal_id); + } catch { + // Goal not found, continue without goal/project + } + } + + if (goal?.project_id) { + const cachedProject = queryClient.getQueryData(queryKeys.projects.detail(goal.project_id)); + if (cachedProject) { + project = cachedProject; + } else { + try { + project = await projectsApi.getById(goal.project_id); + } catch { + // Project not found, continue without project + } } - } catch { - // Goal or project not found, continue without } } From f1fc635661d4bdb260cb6fc804ea8b2b2b7e957f Mon Sep 17 00:00:00 2001 From: Masato Fukushima Date: Sun, 15 Feb 2026 11:05:16 +0900 Subject: [PATCH 2/4] Fix hook dependency in AI planning preload --- apps/web/src/app/ai-planning/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/app/ai-planning/page.tsx b/apps/web/src/app/ai-planning/page.tsx index 41f876f1..dfaab920 100644 --- a/apps/web/src/app/ai-planning/page.tsx +++ b/apps/web/src/app/ai-planning/page.tsx @@ -157,7 +157,7 @@ export default function AIPlanningPage() { setLoadingSchedules(false); setLoadingWeeklyTasks(false); } - }, [hasApiKey, toast]); + }, [hasApiKey]); useEffect(() => { if (!checkingApiKey && hasApiKey === true) { From 3afaba83055453179b2ad2b8628cd1d383482f85 Mon Sep 17 00:00:00 2001 From: Masato Fukushima Date: Sun, 15 Feb 2026 11:09:58 +0900 Subject: [PATCH 3/4] Avoid parallel auth lookups in settings --- apps/web/src/app/settings/page.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/web/src/app/settings/page.tsx b/apps/web/src/app/settings/page.tsx index 62af010f..99a729cf 100644 --- a/apps/web/src/app/settings/page.tsx +++ b/apps/web/src/app/settings/page.tsx @@ -70,10 +70,8 @@ export default function SettingsPage() { } | null>(null) const getAuthContext = async () => { - const [{ data: { user } }, { data: { session } }] = await Promise.all([ - supabase.auth.getUser(), - supabase.auth.getSession(), - ]) + const { data: { user } } = await supabase.auth.getUser() + const { data: { session } } = await supabase.auth.getSession() return { user, session } } From c192848fa27664864df7100465ac1d272c4ea712 Mon Sep 17 00:00:00 2001 From: Masato Fukushima Date: Sun, 15 Feb 2026 11:10:53 +0900 Subject: [PATCH 4/4] update gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 2ca1284e..bbcce7c1 100644 --- a/.gitignore +++ b/.gitignore @@ -199,3 +199,4 @@ static/uploads/ # custom AGENTS.md +tmp/*