Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 0 additions & 41 deletions .github/workflows/api-sync.yml

This file was deleted.

42 changes: 15 additions & 27 deletions src/app/(main)/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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,
});
Comment on lines +21 to +24

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

goalQueries.list()에 대한 prefetch가 제거되었습니다. DashboardDetail 컴포넌트에서 해당 쿼리를 사용하고 있으므로, 서버 사이드에서 미리 데이터를 가져오지 않으면 클라이언트에서 추가적인 네트워크 요청이 발생하여 렌더링 지연(Waterfall)이 생길 수 있습니다. 사용자 경험 향상을 위해 dashboardKeys.summary()와 함께 병렬로 prefetch하고, DashboardDetailHydrationBoundary 안으로 포함시키는 것을 권장합니다.


const dehydratedState = dehydrate(queryClient);

return (
<HydrationBoundary state={dehydratedState}>
<div className="flex w-full flex-col">
<div className="flex w-full flex-col">
<HydrationBoundary state={dehydratedState}>
<DashBoardSummary />
</HydrationBoundary>

<DataBoundary suspenseFallback={<DashboardDetailSkeleton />}>
<DashboardDetail />
</div>
</HydrationBoundary>
</DataBoundary>
Comment on lines +34 to +36

Copilot AI Apr 14, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DataBoundary's suspenseFallback will only render if a child suspends. DashboardDetail uses useQuery (not useSuspenseQuery / suspense: true), so it won’t suspend and DashboardDetailSkeleton likely never appears. If the intent is to show this skeleton during query loading, enable React Query suspense for the queries used by DashboardDetail (or render the skeleton based on isLoading instead).

Copilot uses AI. Check for mistakes.
</div>
);
}
19 changes: 19 additions & 0 deletions src/app/api/dashboard/summary/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
Comment on lines +5 to +16

Copilot AI Apr 14, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The route returns 502 when hasAnySuccess is false, which will also happen for auth failures (e.g., upstream 401 for user/progress/todos when no/expired token). That masks authentication problems as a gateway error and makes it harder for the client to handle login/refresh flows correctly. Consider detecting ApiError rejections with status 401/403 inside getDashboardSummaryResult (or in this route) and returning that status (or a structured error) instead of 502.

Suggested change
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 });
function findAuthStatus(value: unknown, seen = new WeakSet<object>()): 401 | 403 | undefined {
if (!value || (typeof value !== 'object' && typeof value !== 'function')) {
return undefined;
}
const candidate = value as {
status?: unknown;
statusCode?: unknown;
response?: unknown;
cause?: unknown;
error?: unknown;
errors?: unknown;
};
if (candidate.status === 401 || candidate.statusCode === 401) {
return 401;
}
if (candidate.status === 403 || candidate.statusCode === 403) {
return 403;
}
if (seen.has(value as object)) {
return undefined;
}
seen.add(value as object);
const nestedValues = [
candidate.response,
candidate.cause,
candidate.error,
candidate.errors,
...Object.values(candidate),
];
for (const nestedValue of nestedValues) {
const authStatus = findAuthStatus(nestedValue, seen);
if (authStatus) {
return authStatus;
}
}
return undefined;
}
export async function GET() {
try {
const result = await fetchDashboard.getDashboardSummaryResult();
const authStatus = !result.hasAnySuccess ? findAuthStatus(result) : undefined;
return NextResponse.json(result, {
status: authStatus ?? (result.hasAnySuccess ? 200 : 502),
headers: {
'Cache-Control': 'no-store',
},
});
} catch (error) {
const authStatus = findAuthStatus(error);
return NextResponse.json(
{ message: 'Failed to fetch dashboard summary' },
{ status: authStatus ?? 502 },
);

Copilot uses AI. Check for mistakes.
}
}

Original file line number Diff line number Diff line change
@@ -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';
Expand Down
66 changes: 34 additions & 32 deletions src/features/dashboard/components/DashboardSummary/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
});
Comment thread
ramong26 marked this conversation as resolved.

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';
Comment on lines +36 to 38

Copilot AI Apr 14, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

githubGoals is now derived from dashboardSummaryData.todos (recent todos) rather than from the goal list. This changes the connect-modal condition to “no recent GitHub-sourced todos” instead of “no GitHub goals”, which can incorrectly open the GitHub connect modal even when GitHub goals exist but recent todos are all MANUAL. Consider keeping the goals query for this check, or extend the dashboard summary API to include GitHub goal existence/count and use that instead.

Copilot uses AI. Check for mistakes.

Expand All @@ -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;
Expand All @@ -68,11 +69,10 @@ export default function DashBoardSummary() {
}
}, [
mode,
isUserFetched,
isGoalsFetched,
isDashboardSummaryFetched,
githubGoals.length,
openModal,
user?.githubConnected,
dashboardSummaryData?.user?.githubConnected,
isGithubDisconnectedSession,
]);

Expand All @@ -81,7 +81,7 @@ export default function DashBoardSummary() {
<div className="flex items-center justify-end pb-[30px] md:justify-between lg:pb-[34px]">
{breakpoint !== 'mobile' && (
<div className="flex flex-col gap-2">
<PageHeader title={`${user?.nickname}${t.dashboard.title}`} />
<PageHeader title={`${dashboardSummaryData?.user?.nickname}${t.dashboard.title}`} />
{mode === 'GITHUB' && (
<span className="text-xl text-gray-400 transition-all duration-200">{t.dashboard.githubModeDesc}</span>
)}
Expand All @@ -106,35 +106,31 @@ export default function DashBoardSummary() {
</Link>
}
/>

<RecentPostCard />
<RecentPostCard dashboardSummaryData={dashboardSummaryData} />
</div>
<div className="flex min-w-0 flex-1 flex-col justify-between gap-[10px]">
<PageSubTitle
subTitle={t.dashboard.myProgress}
icons={<Image src={'/image/progress-green.png'} alt="Progress Icon" width={40} height={40} />}
/>

<CurrentProgressCard />
<CurrentProgressCard dashboardSummaryData={dashboardSummaryData} />
</div>
</section>
</>
);
}

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 (
<article className="dark:bg-gray-850 flex h-[187px] h-fit w-full min-w-0 flex-col gap-[6px] rounded-[40px] bg-white px-4 py-[18px] md:h-[229px] md:p-4 lg:h-[256px] lg:p-8">
{recentTodos.length > 0 ? (
recentTodos.map((item) => <TaskCardWrapper key={item.id} item={item} mode="todo" />)
{dashboardSummaryData?.todos?.length > 0 ? (
dashboardSummaryData.todos.map((item) => <TaskCardWrapper key={item.id} item={item} mode="todo" />)
) : (
<div className="flex h-full items-center justify-center">
<span className="text-gray-500">{t.dashboard.noRecentTodo}</span>
Expand All @@ -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 (
<article className="bg-bearlog-500 relative h-[187px] w-full rounded-[40px] shadow-[0_10px_40px_0_rgba(2,202,181,0.40)] md:h-[229px] lg:h-[256px]">
<div className="absolute right-0 bottom-0">
Expand All @@ -170,7 +168,11 @@ function CurrentProgressCard() {
</div>
<div className="absolute flex h-full w-full items-center justify-start gap-8 p-6 lg:p-12">
<div className="w-[120px]">
<ProgressCircle percent={percents?.totalProgress ?? 0} className="h-auto w-full" color="#008354" />
<ProgressCircle
percent={dashboardSummaryData?.progress?.totalProgress ?? 0}
className="h-auto w-full"
color="#008354"
/>
</div>
<div className="flex flex-col items-start gap-2">
<div className="flex flex-col items-start">
Expand All @@ -183,7 +185,7 @@ function CurrentProgressCard() {
</div>
<div className="flex items-baseline gap-1">
<span className="text-[clamp(20px,5vw,60px)] leading-[1] font-bold text-white">
{percents?.totalProgress}
{dashboardSummaryData?.progress?.totalProgress}
</span>
<span className="text-[clamp(14px,2vw,30px)] text-white">%</span>
</div>
Expand Down
Loading
Loading