Skip to content
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import useGetDailyComments from './useGetDailyComments';

export { useGetDailyComments };
export { dailyCommentQueries } from './queries';
24 changes: 24 additions & 0 deletions apps/native/src/apis/controller/student/dailyComment/queries.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { queryOptions } from '@tanstack/react-query';

import { client } from '@apis/client';

/**
* Daily comment queryOptions factory.
*
* 계층적 key 구조로 도메인 단위 invalidation 가능:
* queryClient.invalidateQueries({ queryKey: dailyCommentQueries.all() });
*/
export const dailyCommentQueries = {
all: () => ['student', 'daily-comment'] as const,
byDate: (commentDate: string | undefined) =>
queryOptions({
queryKey: [...dailyCommentQueries.all(), { commentDate }] as const,
queryFn: async () => {
const response = await client.GET('/api/student/daily-comments', {
params: { query: { commentDate } },
});
return response.data ?? [];
},
staleTime: 5 * 60 * 1000,
}),
};
Original file line number Diff line number Diff line change
@@ -1,22 +1,13 @@
import { useQuery } from '@tanstack/react-query';

import { client } from '@/apis/client';
import { dailyCommentQueries } from './queries';

/**
* 특정 일자 데일리 코멘트 조회.
* `commentDate` 미지정 시 서버 기본값(오늘) 사용.
*/
const useGetDailyComments = (commentDate?: string) => {
return useQuery({
queryKey: ['get', '/api/student/daily-comments', commentDate],
queryFn: async () => {
const response = await client.GET('/api/student/daily-comments', {
params: { query: { commentDate } },
});
return response.data ?? [];
},
staleTime: 5 * 60 * 1000,
});
return useQuery(dailyCommentQueries.byDate(commentDate));
};

export default useGetDailyComments;
1 change: 1 addition & 0 deletions apps/native/src/apis/controller/student/focusCard/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import useGetFocusCards from './useGetFocusCards';

export { useGetFocusCards };
export { focusCardQueries } from './queries';
27 changes: 27 additions & 0 deletions apps/native/src/apis/controller/student/focusCard/queries.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { queryOptions } from '@tanstack/react-query';

import { client } from '@apis/client';

/**
* Focus card queryOptions factory.
*
* 계층적 key 구조로 도메인 단위 invalidation 가능:
* queryClient.invalidateQueries({ queryKey: focusCardQueries.all() });
*
* 응답의 envelope(`requestId`/`total`)는 클라이언트에서 쓰지 않으므로 `data`만 추출해
* `FocusCardIssuanceResp[]`로 반환한다.
*/
export const focusCardQueries = {
all: () => ['student', 'focus-card'] as const,
byDate: (date: string | undefined) =>
queryOptions({
queryKey: [...focusCardQueries.all(), { date }] as const,
queryFn: async () => {
const response = await client.GET('/api/student/focus-card', {
params: { query: { date } },
});
return response.data?.data ?? [];
},
staleTime: 5 * 60 * 1000,
}),
};
Original file line number Diff line number Diff line change
@@ -1,22 +1,13 @@
import { useQuery } from '@tanstack/react-query';

import { client } from '@/apis/client';
import { focusCardQueries } from './queries';

/**
* 특정 날짜에 발급된 집중학습 카드 조회.
* `date` 미지정 시 서버 기본값(오늘) 사용.
*/
const useGetFocusCards = (date?: string) => {
return useQuery({
queryKey: ['get', '/api/student/focus-card', date],
queryFn: async () => {
const response = await client.GET('/api/student/focus-card', {
params: { query: { date } },
});
return response.data ?? null;
},
staleTime: 5 * 60 * 1000,
});
return useQuery(focusCardQueries.byDate(date));
};

export default useGetFocusCards;
41 changes: 41 additions & 0 deletions apps/native/src/features/student/home/hooks/useHomeFocusCards.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { useMemo } from 'react';
import { useQueries } from '@tanstack/react-query';

import { focusCardQueries } from '@apis/controller/student/focusCard';
import { formatDateKey } from '@utils/date';
import type { components } from '@schema';

type FocusCardIssuanceResp = components['schemas']['FocusCardIssuanceResp'];

/**
* 홈 화면용 집중학습 카드 — 오늘 + 내일 발급분을 합쳐 반환.
*
* TODO: 백엔드가 `from`/`to` 범위 endpoint를 제공하면 단일 쿼리로 교체.
* 현재는 임시 join.
*/
export function useHomeFocusCards(today: Date): {
data: FocusCardIssuanceResp[];
isPending: boolean;
isError: boolean;
error: Error | null;
} {
const todayStr = formatDateKey(today);
const tomorrow = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 1);
const tomorrowStr = formatDateKey(tomorrow);

const [todayQuery, tomorrowQuery] = useQueries({
queries: [focusCardQueries.byDate(todayStr), focusCardQueries.byDate(tomorrowStr)],
});

const data = useMemo(
() => [...(todayQuery.data ?? []), ...(tomorrowQuery.data ?? [])],
[todayQuery.data, tomorrowQuery.data]
);

return {
data,
isPending: todayQuery.isPending || tomorrowQuery.isPending,
isError: todayQuery.isError || tomorrowQuery.isError,
error: todayQuery.error ?? tomorrowQuery.error ?? null,
};
}
59 changes: 23 additions & 36 deletions apps/native/src/features/student/home/screens/HomeScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,56 +1,60 @@
import React, { useMemo, useState } from 'react';
import React, { useCallback, useMemo, useState } from 'react';
import { View } from 'react-native';
// import { useNavigation } from '@react-navigation/native';
// import { type NativeStackNavigationProp } from '@react-navigation/native-stack';
import { useFocusEffect } from '@react-navigation/native';
import { useQueryClient } from '@tanstack/react-query';

import { useAuthStore, useHomeStore } from '@stores';
import {
useGetMonthlyPublish,
useGetPublishDetail,
useGetDailyComments,
useGetFocusCards,
// useGetNotificationCount,
// useGetNoticeCount,
dailyCommentQueries,
focusCardQueries,
} from '@apis';
// import { type StudentRootStackParamList } from '@navigation/student/types';
import { PointerLogo } from '@components/system/icons';
import { ContentInset, PointerContentView } from '@components/common';
// import { useInvalidateAll } from '@hooks';
import { formatDateKey } from '@utils/date';

import { buildHomeInit } from '../transforms/homeContentTransforms';
import { useHomeFocusCards } from '../hooks/useHomeFocusCards';
import ProblemSet from '../components/ProblemSet';
import CalendarModal from '../components/CalendarModal';

const HomeScreen = () => {
// const navigation = useNavigation<NativeStackNavigationProp<StudentRootStackParamList>>();
const { selectedMonth, selectedDate, setSelectedMonth, setSelectedDate } = useHomeStore();
const [isCalendarModalVisible, setIsCalendarModalVisible] = useState(false);
const studentName = useAuthStore((state) => state.studentProfile?.name);
const queryClient = useQueryClient();

const { data: studyData } = useGetMonthlyPublish({
year: selectedMonth.getFullYear(),
month: selectedMonth.getMonth() + 1,
});

// ── 홈 카드 API ──
const todayStr = formatDateKey(new Date());
// 마운트 시점의 오늘 — 디바이스 자정 넘김 시 자동 갱신은 별도 처리(useFocusEffect)에 위임.
const today = useMemo(() => new Date(), []);
const todayStr = useMemo(() => formatDateKey(today), [today]);
const { data: dailyComments } = useGetDailyComments(todayStr);
const { data: focusCards } = useGetFocusCards(todayStr);
const { data: focusCardItems } = useHomeFocusCards(today);

// 화면 진입 시 홈 카드 데이터 invalidate — 다른 탭/화면 다녀와도 최신 상태 유지
useFocusEffect(
useCallback(() => {
queryClient.invalidateQueries({ queryKey: dailyCommentQueries.all() });
queryClient.invalidateQueries({ queryKey: focusCardQueries.all() });
}, [queryClient])
);

const homeInit = useMemo(() => {
if (!studentName) return null;
return buildHomeInit({
name: studentName,
comments: dailyComments ?? undefined,
focusCards: focusCards ?? undefined,
todayStr,
comments: dailyComments,
focusCardItems,
});
}, [studentName, dailyComments, focusCards]);

// const { data: notificationCountData } = useGetNotificationCount({});
// const { data: noticeCountData } = useGetNoticeCount();

// const hasUnread = !!(notificationCountData?.unreadCount || noticeCountData?.unreadCount);
}, [studentName, todayStr, dailyComments, focusCardItems]);

const selectedPublishId = useMemo(() => {
if (!studyData?.data) return -1;
Expand All @@ -70,25 +74,8 @@ const HomeScreen = () => {
}
};

// const { invalidateAll } = useInvalidateAll();
// const [refreshing, setRefreshing] = useState(false);

// const onRefresh = async () => {
// setRefreshing(true);
// await invalidateAll();
// setRefreshing(false);
// };

return (
<View className='flex-1'>
{/*<Header
right={
<Header.IconButton
icon={hasUnread ? AlertBellButtonIcon : BellIcon}
onPress={() => navigation.navigate('Notifications')}
/>
}
/>*/}
<ContentInset className='flex h-[56px] justify-center'>
<View className='flex h-[40px] w-[120px] items-center justify-center'>
<PointerLogo width={106} height={24} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,42 +8,36 @@ import type {
HomeStudySummaryCard,
HomeStudyGroup,
HomeStudyItem,
HomeStudyBadge,
JSONNode,
} from '@repo/pointer-content-renderer';

import type { components } from '@schema';
import { parseTipTapDoc } from '@features/student/problem/transforms/contentRendererTransforms';
import { parseTipTapDoc } from '@utils/tiptap';

type DailyCommentResp = components['schemas']['DailyCommentResp'];
type FocusCardIssuanceResp = components['schemas']['FocusCardIssuanceResp'];
type ListRespFocusCardIssuanceResp = components['schemas']['ListRespFocusCardIssuanceResp'];

/**
* 데일리 코멘트 → HomeCommentCard 변환.
* expiryAt 로부터 남은 시간 계산.
*/
function toCommentCard(comment: DailyCommentResp): HomeCommentCard {
const now = Date.now();
const expiry = comment.expiryAt ? new Date(comment.expiryAt).getTime() : now;
const remainingMs = Math.max(0, expiry - now);
const remainingHours = Math.ceil(remainingMs / (1000 * 60 * 60));

function toCommentCard(comment: DailyCommentResp, name: string): HomeCommentCard {
return {
type: 'comment',
timeRemainingInHours: remainingHours,
title: `${name}님을 위한 1:1 코멘트`,
subtitle: '출제진이 직접 작성한 코멘트에요.',
expiryAt: comment.expiryAt ? new Date(comment.expiryAt).getTime() : null,
content: parseTipTapDoc(comment.contentJson),
};
}

/**
* 집중학습 카드 목록 → HomeStudySummaryCard 변환.
* 발급일 기준으로 오늘/다가오는 학습 그룹 분리.
* 발급일이 `todayStr` 인 항목은 "오늘의 학습", 그 외는 "다가오는 학습" 그룹으로 분리.
*/
function toStudySummaryCard(issuances: FocusCardIssuanceResp[]): HomeStudySummaryCard {
const today = new Date();
const todayStr = formatLocalDate(today);

function toStudySummaryCard(
issuances: FocusCardIssuanceResp[],
name: string,
todayStr: string
): HomeStudySummaryCard {
const todayItems: HomeStudyItem[] = [];
const upcomingItems: HomeStudyItem[] = [];

Expand All @@ -55,7 +49,8 @@ function toStudySummaryCard(issuances: FocusCardIssuanceResp[]): HomeStudySummar
variant: issuance.issuedDate === todayStr ? 'orange' : 'green',
},
],
headerText: issuance.card.description || '',
title: parseTipTapDoc(issuance.card.title),
description: parseTipTapDoc(issuance.card.description),
content: parseTipTapDoc(issuance.card.content),
};

Expand All @@ -76,43 +71,40 @@ function toStudySummaryCard(issuances: FocusCardIssuanceResp[]): HomeStudySummar

return {
type: 'study-summary',
title: `${name}님을 위한 학습 내용 정리`,
subtitle: `${name}님의 학습을 분석해 취약점을 도출했어요.\n지금 바로 출제진의 문제 접근법을 배워봐요.`,
groups,
};
}

function formatLocalDate(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}

/**
* Home mode init 메시지 구성.
*
* @param todayStr "오늘" 판정 기준. 호출자(스크린)가 단일 source-of-truth로 보유 및 전달.
*/
export function buildHomeInit(opts: {
name: string;
todayStr: string;
comments?: DailyCommentResp[];
focusCards?: ListRespFocusCardIssuanceResp | null;
focusCardItems?: FocusCardIssuanceResp[];
}): (RNToWebViewMessage & { type: 'init'; mode: 'home' }) | null {
const cards: HomeCard[] = [];

// 1:1 코멘트 (첫 번째만)
// 1:1 코멘트 (첫 번째만 표시 — 현재 기획상 동시 노출 1건)
if (opts.comments && opts.comments.length > 0) {
cards.push(toCommentCard(opts.comments[0]));
cards.push(toCommentCard(opts.comments[0], opts.name));
}

// 학습 내용 정리
if (opts.focusCards?.data && opts.focusCards.data.length > 0) {
cards.push(toStudySummaryCard(opts.focusCards.data));
if (opts.focusCardItems && opts.focusCardItems.length > 0) {
cards.push(toStudySummaryCard(opts.focusCardItems, opts.name, opts.todayStr));
}

if (cards.length === 0) return null;

return {
type: 'init',
mode: 'home',
name: opts.name,
cards,
};
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { components } from '@schema';
import { parseTipTapDoc } from '@utils/tiptap';

import {
buildAllPointingsLeftSections,
Expand All @@ -7,7 +8,6 @@ import {
buildDocumentInit,
joinBubblesToDoc,
joinPointingsForAnalysis,
parseTipTapDoc,
toAnswerNodes,
toBubbleNodes,
toChatScenario,
Expand Down
Loading
Loading