diff --git a/.github/workflows/api-sync.yml b/.github/workflows/api-sync.yml deleted file mode 100644 index b3781255..00000000 --- a/.github/workflows/api-sync.yml +++ /dev/null @@ -1,41 +0,0 @@ -name: Frontend Type Sync - -on: - pull_request: - push: - branches: [main, develop] - -jobs: - type-sync: - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup pnpm - uses: pnpm/action-setup@v3 - with: - version: 8 - - - name: Setup Node - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: 'pnpm' - - - name: Install deps - run: pnpm install --frozen-lockfile - - - name: pnpm build - run: pnpm run build - - - name: Generate API types - run: pnpm exec openapi-typescript ${{ secrets.OPENAPI_URL }} -o src/shared/types/api/schemas/api.types.ts - - - name: Type check - run: pnpm run typecheck - - - name: Check for generated file changes - run: | - git diff --exit-code src/shared/types/api/schemas/api.types.ts diff --git a/src/app/(main)/dashboard/page.tsx b/src/app/(main)/dashboard/page.tsx index b150150b..4fcb7741 100644 --- a/src/app/(main)/dashboard/page.tsx +++ b/src/app/(main)/dashboard/page.tsx @@ -2,8 +2,11 @@ import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query import DashBoardSummary from '@/features/dashboard/components/DashboardSummary'; import DashboardDetail from '@/features/dashboard/components/DashboardDetail'; +import DashboardDetailSkeleton from '@/features/dashboard/components/DashboardDetailSkeleton'; -import { todoQueries, userQueries, goalQueries } from '@/shared/lib/query/queryKeys'; +import { dashboardKeys } from '@/shared/lib/query/keyFactory'; +import { getDashboardSummary } from '@/features/dashboard/lib/getDashboardSummary'; +import { DataBoundary } from '@/shared/components/ErrorSuspenseBoundary'; export const dynamic = 'force-dynamic'; @@ -15,37 +18,22 @@ export const dynamic = 'force-dynamic'; export default async function DashboardPage() { const queryClient = new QueryClient(); - await Promise.all([ - queryClient.prefetchQuery(userQueries.current()), - queryClient.prefetchQuery(userQueries.progress()), - ]); - - const goals = await queryClient.fetchQuery(goalQueries.list()); - - await Promise.all( - (goals.goals ?? []) - .filter((goal) => goal.id != null) - .flatMap((goal) => [ - queryClient.prefetchQuery(goalQueries.detail(goal.id!)), - queryClient.prefetchInfiniteQuery({ - ...todoQueries.infiniteList({ goalId: goal.id!, done: false, limit: 10 }), - pages: 1, - }), - queryClient.prefetchInfiniteQuery({ - ...todoQueries.infiniteList({ goalId: goal.id!, done: true, limit: 10 }), - pages: 1, - }), - ]), - ); + await queryClient.prefetchQuery({ + queryKey: dashboardKeys.summary(), + queryFn: async () => (await getDashboardSummary()).data, + }); const dehydratedState = dehydrate(queryClient); return ( - -
+
+ + + + }> -
- + +
); } diff --git a/src/app/api/dashboard/summary/route.ts b/src/app/api/dashboard/summary/route.ts new file mode 100644 index 00000000..c695fbdc --- /dev/null +++ b/src/app/api/dashboard/summary/route.ts @@ -0,0 +1,19 @@ +import { NextResponse } from 'next/server'; + +import { fetchDashboard } from '@/shared/lib/api/fetchDashboard'; + +export async function GET() { + try { + const result = await fetchDashboard.getDashboardSummaryResult(); + + return NextResponse.json(result, { + status: result.hasAnySuccess ? 200 : 502, + headers: { + 'Cache-Control': 'no-store', + }, + }); + } catch { + return NextResponse.json({ message: 'Failed to fetch dashboard summary' }, { status: 502 }); + } +} + diff --git a/src/features/dashboard/components/DashboardDetail/index.tsx b/src/features/dashboard/components/DashboardDetail/index.tsx index ed635659..6e2f15dd 100644 --- a/src/features/dashboard/components/DashboardDetail/index.tsx +++ b/src/features/dashboard/components/DashboardDetail/index.tsx @@ -1,7 +1,7 @@ 'use client'; import Image from 'next/image'; -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import Empty from '@/shared/components/Empty'; diff --git a/src/features/dashboard/components/DashboardSummary/index.tsx b/src/features/dashboard/components/DashboardSummary/index.tsx index 485ab5f3..64f31d24 100644 --- a/src/features/dashboard/components/DashboardSummary/index.tsx +++ b/src/features/dashboard/components/DashboardSummary/index.tsx @@ -14,25 +14,26 @@ import TaskCardWrapper from '../TaskCardWrapper'; import GithubRepoConnectModal from '@/shared/components/Modal/GithubRepoConnectModal'; import { useBreakpoint } from '@/shared/hooks/useBreakPoint'; - -import { todoQueries, userQueries, goalQueries } from '@/shared/lib/query/queryKeys'; +import { dashboardQueries } from '@/shared/lib/query/queryKeys'; import { useModalStore } from '@/shared/stores/useModalStore'; import { useTodoModeStore, TodoMode } from '@/shared/stores/useTodoModeStore'; import { useLanguage } from '@/shared/contexts/LanguageContext'; import { GITHUB_DISCONNECTED_SESSION_KEY } from '@/shared/constants/github'; +import { DashboardSummaryResponse } from '@/shared/types/api/schemas/api.process'; export default function DashBoardSummary() { const { t } = useLanguage(); - - const { data: user, isFetched: isUserFetched } = useQuery(userQueries.current()); - const { data: goals, isFetched: isGoalsFetched } = useQuery(goalQueries.list()); const breakpoint = useBreakpoint(); - const mode = useTodoModeStore((state) => state.mode); const setMode = useTodoModeStore((state) => state.setMode); + + const { data: dashboardSummaryData, isFetched: isDashboardSummaryFetched } = useQuery({ + ...dashboardQueries.summary(), + }); + const { openModal } = useModalStore(); - const githubGoals = goals?.goals?.filter((goal) => goal.source === 'GITHUB') ?? []; + const githubGoals = dashboardSummaryData?.todos?.filter((goal) => goal.source === 'GITHUB') ?? []; const isGithubDisconnectedSession = typeof window !== 'undefined' && window.sessionStorage.getItem(GITHUB_DISCONNECTED_SESSION_KEY) === 'true'; @@ -48,14 +49,14 @@ export default function DashBoardSummary() { return; } - if (!isUserFetched) { + if (!isDashboardSummaryFetched) { return; } const shouldOpenConnectModal = isGithubDisconnectedSession || - !user?.githubConnected || - (user.githubConnected && isGoalsFetched && githubGoals.length === 0); + !dashboardSummaryData?.user?.githubConnected || + (dashboardSummaryData?.user?.githubConnected && isDashboardSummaryFetched && githubGoals.length === 0); if (!shouldOpenConnectModal) { githubModalOpenedRef.current = false; @@ -68,11 +69,10 @@ export default function DashBoardSummary() { } }, [ mode, - isUserFetched, - isGoalsFetched, + isDashboardSummaryFetched, githubGoals.length, openModal, - user?.githubConnected, + dashboardSummaryData?.user?.githubConnected, isGithubDisconnectedSession, ]); @@ -81,7 +81,7 @@ export default function DashBoardSummary() {
{breakpoint !== 'mobile' && (
- + {mode === 'GITHUB' && ( {t.dashboard.githubModeDesc} )} @@ -106,35 +106,31 @@ export default function DashBoardSummary() { } /> - - +
} /> - - +
); } -function RecentPostCard() { +interface RecentPostCardProps { + dashboardSummaryData: DashboardSummaryResponse | undefined; +} +function RecentPostCard({ dashboardSummaryData }: RecentPostCardProps) { const { t } = useLanguage(); - const { data: todos } = useQuery( - todoQueries.list({ - sort: 'LATEST', - }), - ); - const recentTodos = todos?.todos?.slice(0, 4) ?? []; + if (!dashboardSummaryData || dashboardSummaryData.todos.length === 0) return null; return (
- {recentTodos.length > 0 ? ( - recentTodos.map((item) => ) + {dashboardSummaryData?.todos?.length > 0 ? ( + dashboardSummaryData.todos.map((item) => ) ) : (
{t.dashboard.noRecentTodo} @@ -144,12 +140,14 @@ function RecentPostCard() { ); } -function CurrentProgressCard() { +interface CurrentProgressCardProps { + dashboardSummaryData: DashboardSummaryResponse | undefined; +} +function CurrentProgressCard({ dashboardSummaryData }: CurrentProgressCardProps) { const { t } = useLanguage(); const mode = useTodoModeStore((state) => state.mode); - const { data: percents } = useQuery(userQueries.progress()); - + if (!dashboardSummaryData || dashboardSummaryData.progress === null) return null; return (
@@ -170,7 +168,11 @@ function CurrentProgressCard() {
- +
@@ -183,7 +185,7 @@ function CurrentProgressCard() {
- {percents?.totalProgress} + {dashboardSummaryData?.progress?.totalProgress} %
diff --git a/src/perf/dashboard-http.js b/src/perf/dashboard-http.js index bf95bc4e..66a1c5b0 100644 --- a/src/perf/dashboard-http.js +++ b/src/perf/dashboard-http.js @@ -5,6 +5,7 @@ const BASE_URL = (__ENV.BASE_URL || 'http://localhost:3000').replace(/\/+$/, '') const TARGET_MODE = (__ENV.TARGET_MODE || 'proxy').toLowerCase(); const DASHBOARD_MODE = (__ENV.DASHBOARD_MODE || 'MANUAL').toUpperCase(); const APPLY_MODE_FILTER = (__ENV.APPLY_MODE_FILTER || 'false').toLowerCase() === 'true'; +const SUMMARY_FETCH_MODE = (__ENV.SUMMARY_FETCH_MODE || 'legacy').toLowerCase(); const AUTH_COOKIE = __ENV.AUTH_COOKIE || ''; const ACCESS_TOKEN = __ENV.ACCESS_TOKEN || ''; const REFRESH_TOKEN = __ENV.REFRESH_TOKEN || ''; @@ -69,36 +70,64 @@ export const options = { export function dashboardApiLoad() { const headers = buildHeaders(); + const useSummaryApi = SUMMARY_FETCH_MODE === 'summary-api' && TARGET_MODE === 'proxy'; + let goalsRes = null; + let recentTodos = []; - // Initial requests fired together, similar to dashboard first render. - const bootstrapResponses = http.batch([ - ['GET', buildUrl('/api/v1/users/me'), null, requestParams(headers, 'dashboard-current-user')], - ['GET', buildUrl('/api/v1/users/me/progress'), null, requestParams(headers, 'dashboard-progress')], - ['GET', buildUrl('/api/v1/goals', { limit: GOAL_LIST_LIMIT }), null, requestParams(headers, 'dashboard-goals')], - [ - 'GET', - buildUrl('/api/v1/todos', { sort: 'LATEST', search: '', limit: RECENT_TODO_LIMIT }), - null, - requestParams(headers, 'dashboard-recent-todos'), - ], - ]); + if (useSummaryApi) { + // New path: dashboard summary BFF endpoint + goals list. + const [summaryRes, goalsResponse] = http.batch([ + ['GET', buildAppUrl('/api/dashboard/summary'), null, requestParams(headers, 'dashboard-summary-api')], + ['GET', buildUrl('/api/v1/goals', { limit: GOAL_LIST_LIMIT }), null, requestParams(headers, 'dashboard-goals')], + ]); - const [currentUserRes, progressRes, goalsRes, recentTodosRes] = bootstrapResponses; + goalsRes = goalsResponse; - check(currentUserRes, { - 'current user status is 200': (response) => response.status === 200, - }); - check(progressRes, { - 'progress status is 200': (response) => response.status === 200, - }); - check(goalsRes, { - 'goals status is 200': (response) => response.status === 200, - }); - check(recentTodosRes, { - 'recent todos status is 200': (response) => response.status === 200, - }); + check(summaryRes, { + 'dashboard summary api status is 200': (response) => response.status === 200, + }); + check(goalsRes, { + 'goals status is 200': (response) => response.status === 200, + }); + + if (summaryRes.status === 200) { + recentTodos = parseJson(summaryRes)?.data?.todos ?? []; + } + } else { + // Legacy path: user/progress/recent-todo requests are sent independently. + const [currentUserRes, progressRes, goalsResponse, recentTodosRes] = http.batch([ + ['GET', buildUrl('/api/v1/users/me'), null, requestParams(headers, 'dashboard-current-user')], + ['GET', buildUrl('/api/v1/users/me/progress'), null, requestParams(headers, 'dashboard-progress')], + ['GET', buildUrl('/api/v1/goals', { limit: GOAL_LIST_LIMIT }), null, requestParams(headers, 'dashboard-goals')], + [ + 'GET', + buildUrl('/api/v1/todos', { sort: 'LATEST', search: '', limit: RECENT_TODO_LIMIT }), + null, + requestParams(headers, 'dashboard-recent-todos'), + ], + ]); + + goalsRes = goalsResponse; + + check(currentUserRes, { + 'current user status is 200': (response) => response.status === 200, + }); + check(progressRes, { + 'progress status is 200': (response) => response.status === 200, + }); + check(goalsRes, { + 'goals status is 200': (response) => response.status === 200, + }); + check(recentTodosRes, { + 'recent todos status is 200': (response) => response.status === 200, + }); + + if (recentTodosRes.status === 200) { + recentTodos = parseJson(recentTodosRes)?.todos ?? []; + } + } - if (goalsRes.status !== 200 && recentTodosRes.status !== 200) { + if (goalsRes.status !== 200 && recentTodos.length === 0) { sleep(SLEEP_SECONDS); return; } @@ -154,12 +183,9 @@ export function dashboardApiLoad() { } } - if (recentTodosRes.status === 200) { - // Recent todo cards also call detail API per item. - const recentTodos = parseJson(recentTodosRes)?.todos ?? []; - for (const todoId of recentTodos.map((todo) => todo?.id).filter((id) => Number.isFinite(id) && id > 0)) { - todoDetailIds.add(todoId); - } + // Recent todo cards also call detail API per item. + for (const todoId of recentTodos.map((todo) => todo?.id).filter((id) => Number.isFinite(id) && id > 0)) { + todoDetailIds.add(todoId); } const todoDetailRequests = Array.from(todoDetailIds).map((todoId) => [ @@ -233,6 +259,11 @@ function buildUrl(pathname, params) { return queryString ? `${BASE_URL}${normalizedPath}?${queryString}` : `${BASE_URL}${normalizedPath}`; } +function buildAppUrl(pathname, params) { + const queryString = toQueryString(params); + return queryString ? `${BASE_URL}${pathname}?${queryString}` : `${BASE_URL}${pathname}`; +} + function toProxyPath(pathname) { // /api/v1/* -> /api/proxy/* mapping for Next.js BFF route. return pathname.replace(/^\/api\/v1\/?/, '/api/proxy/'); @@ -245,6 +276,7 @@ function requestParams(headers, name) { name, dashboard_mode: DASHBOARD_MODE, target_mode: TARGET_MODE, + summary_fetch_mode: SUMMARY_FETCH_MODE, }, }; } diff --git a/src/shared/lib/api/fetchDashboard.ts b/src/shared/lib/api/fetchDashboard.ts index 94d3bf23..5d06bd10 100644 --- a/src/shared/lib/api/fetchDashboard.ts +++ b/src/shared/lib/api/fetchDashboard.ts @@ -50,8 +50,26 @@ class FetchDashboard { }; getDashboardSummary = async (): Promise => { - const { data } = await this.getDashboardSummaryResult(); - return data; + if (typeof window === 'undefined') { + const { data } = await this.getDashboardSummaryResult(); + return data; + } + + const response = await fetch('/api/dashboard/summary', { + method: 'GET', + headers: { + Accept: 'application/json', + }, + credentials: 'include', + cache: 'no-store', + }); + + if (!response.ok) { + throw new Error(`Dashboard summary request failed: ${response.status}`); + } + + const payload = (await response.json()) as DashboardSummaryResult; + return payload.data; }; } diff --git a/src/shared/lib/query/keyFactory.ts b/src/shared/lib/query/keyFactory.ts index 129126bb..88cfbff7 100644 --- a/src/shared/lib/query/keyFactory.ts +++ b/src/shared/lib/query/keyFactory.ts @@ -54,3 +54,8 @@ export const tagKeys = { all: ['tags'] as const, lists: () => [...tagKeys.all, 'list'] as const, }; + +export const dashboardKeys = { + all: ['dashboard'] as const, + summary: () => [...dashboardKeys.all, 'summary'] as const, +}; diff --git a/src/shared/lib/query/queryKeys.ts b/src/shared/lib/query/queryKeys.ts index 8dc2ccba..1f1d5aa8 100644 --- a/src/shared/lib/query/queryKeys.ts +++ b/src/shared/lib/query/queryKeys.ts @@ -8,21 +8,34 @@ import { fetchNotifications } from '../api/fetchNotifications'; import { fetchTags } from '../api/fetchTags'; import { fetchTodos, type GetTodosParams, type GetTodoCalendarParams } from '../api/fetchTodos'; import { fetchUsers } from '../api/fetchUsers'; - -import { authKeys, githubKeys, goalKeys, noteKeys, notificationKeys, tagKeys, todoKeys, userKeys } from './keyFactory'; - +import { + authKeys, + dashboardKeys, + githubKeys, + goalKeys, + noteKeys, + notificationKeys, + tagKeys, + todoKeys, + userKeys, +} from './keyFactory'; +import { fetchDashboard } from '../api/fetchDashboard'; + +const DASHBOARD_STALE_TIME = 1000 * 60 * 5; // goal queries export const goalQueries = { list: (params?: GetGoalsParams) => queryOptions({ queryKey: goalKeys.list(params), queryFn: () => fetchGoals.getGoals(params), + staleTime: DASHBOARD_STALE_TIME, }), detail: (goalId: number) => queryOptions({ queryKey: goalKeys.detail(goalId), queryFn: () => fetchGoals.getGoal(goalId), + staleTime: DASHBOARD_STALE_TIME, }), }; @@ -32,6 +45,7 @@ export const todoQueries = { queryOptions({ queryKey: todoKeys.list(params), queryFn: () => fetchTodos.getTodos(params), + staleTime: DASHBOARD_STALE_TIME, }), infiniteList: (params?: Omit) => @@ -40,12 +54,14 @@ export const todoQueries = { queryFn: ({ pageParam }) => fetchTodos.getTodos({ ...params, cursor: pageParam }), initialPageParam: undefined as number | undefined, getNextPageParam: (lastPage) => (lastPage.hasMore ? (lastPage.nextCursor ?? undefined) : undefined), + staleTime: DASHBOARD_STALE_TIME, }), detail: (todoId: number) => queryOptions({ queryKey: todoKeys.detail(todoId), queryFn: () => fetchTodos.getTodo(todoId), + staleTime: DASHBOARD_STALE_TIME, }), calendar: (params: GetTodoCalendarParams) => @@ -99,8 +115,7 @@ export const githubQueries = { queryOptions({ queryKey: githubKeys.repositories(), queryFn: fetchGithubIntegrations.getRepositories, - - staleTime: 5 * 60 * 1000, + staleTime: DASHBOARD_STALE_TIME, retry: false, refetchOnWindowFocus: false, }), @@ -112,12 +127,14 @@ export const userQueries = { queryOptions({ queryKey: userKeys.me(), queryFn: fetchUsers.getCurrentUser, + staleTime: DASHBOARD_STALE_TIME, }), progress: () => queryOptions({ queryKey: userKeys.progress(), queryFn: fetchUsers.getUserProgress, + staleTime: DASHBOARD_STALE_TIME, }), githubConnection: () => @@ -144,3 +161,13 @@ export const tagQueries = { queryFn: fetchTags.getTags, }), }; + +// dashboard queries +export const dashboardQueries = { + summary: () => + queryOptions({ + queryKey: dashboardKeys.summary(), + queryFn: () => fetchDashboard.getDashboardSummary(), + staleTime: DASHBOARD_STALE_TIME, + }), +};