diff --git a/apps/native/src/apis/controller/student/dailyComment/index.ts b/apps/native/src/apis/controller/student/dailyComment/index.ts index b0c37a6d9..b312597d0 100644 --- a/apps/native/src/apis/controller/student/dailyComment/index.ts +++ b/apps/native/src/apis/controller/student/dailyComment/index.ts @@ -1,3 +1,4 @@ import useGetDailyComments from './useGetDailyComments'; export { useGetDailyComments }; +export { dailyCommentQueries } from './queries'; diff --git a/apps/native/src/apis/controller/student/dailyComment/queries.ts b/apps/native/src/apis/controller/student/dailyComment/queries.ts new file mode 100644 index 000000000..5d44161bb --- /dev/null +++ b/apps/native/src/apis/controller/student/dailyComment/queries.ts @@ -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, + }), +}; diff --git a/apps/native/src/apis/controller/student/dailyComment/useGetDailyComments.ts b/apps/native/src/apis/controller/student/dailyComment/useGetDailyComments.ts index ecdd204fd..4c1d797e7 100644 --- a/apps/native/src/apis/controller/student/dailyComment/useGetDailyComments.ts +++ b/apps/native/src/apis/controller/student/dailyComment/useGetDailyComments.ts @@ -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; diff --git a/apps/native/src/apis/controller/student/focusCard/index.ts b/apps/native/src/apis/controller/student/focusCard/index.ts index 04788653d..33b24db66 100644 --- a/apps/native/src/apis/controller/student/focusCard/index.ts +++ b/apps/native/src/apis/controller/student/focusCard/index.ts @@ -1,3 +1,4 @@ import useGetFocusCards from './useGetFocusCards'; export { useGetFocusCards }; +export { focusCardQueries } from './queries'; diff --git a/apps/native/src/apis/controller/student/focusCard/queries.ts b/apps/native/src/apis/controller/student/focusCard/queries.ts new file mode 100644 index 000000000..33b97faaa --- /dev/null +++ b/apps/native/src/apis/controller/student/focusCard/queries.ts @@ -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, + }), +}; diff --git a/apps/native/src/apis/controller/student/focusCard/useGetFocusCards.ts b/apps/native/src/apis/controller/student/focusCard/useGetFocusCards.ts index 0b7cf2116..6bb2fed51 100644 --- a/apps/native/src/apis/controller/student/focusCard/useGetFocusCards.ts +++ b/apps/native/src/apis/controller/student/focusCard/useGetFocusCards.ts @@ -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; diff --git a/apps/native/src/features/student/home/hooks/useHomeFocusCards.ts b/apps/native/src/features/student/home/hooks/useHomeFocusCards.ts new file mode 100644 index 000000000..2406d8ef8 --- /dev/null +++ b/apps/native/src/features/student/home/hooks/useHomeFocusCards.ts @@ -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, + }; +} diff --git a/apps/native/src/features/student/home/screens/HomeScreen.tsx b/apps/native/src/features/student/home/screens/HomeScreen.tsx index d1c9007ae..6c15bd024 100644 --- a/apps/native/src/features/student/home/screens/HomeScreen.tsx +++ b/apps/native/src/features/student/home/screens/HomeScreen.tsx @@ -1,32 +1,30 @@ -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>(); 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(), @@ -34,23 +32,29 @@ const HomeScreen = () => { }); // ── 홈 카드 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; @@ -70,25 +74,8 @@ const HomeScreen = () => { } }; - // const { invalidateAll } = useInvalidateAll(); - // const [refreshing, setRefreshing] = useState(false); - - // const onRefresh = async () => { - // setRefreshing(true); - // await invalidateAll(); - // setRefreshing(false); - // }; - return ( - {/*
navigation.navigate('Notifications')} - /> - } - />*/} diff --git a/apps/native/src/features/student/home/transforms/homeContentTransforms.ts b/apps/native/src/features/student/home/transforms/homeContentTransforms.ts index 5340112c6..1b6ed2e4d 100644 --- a/apps/native/src/features/student/home/transforms/homeContentTransforms.ts +++ b/apps/native/src/features/student/home/transforms/homeContentTransforms.ts @@ -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[] = []; @@ -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), }; @@ -76,35 +71,33 @@ 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; @@ -112,7 +105,6 @@ export function buildHomeInit(opts: { return { type: 'init', mode: 'home', - name: opts.name, cards, }; } diff --git a/apps/native/src/features/student/problem/transforms/__tests__/contentRendererTransforms.test.ts b/apps/native/src/features/student/problem/transforms/__tests__/contentRendererTransforms.test.ts index 3b2088cb4..4b0a01eb1 100644 --- a/apps/native/src/features/student/problem/transforms/__tests__/contentRendererTransforms.test.ts +++ b/apps/native/src/features/student/problem/transforms/__tests__/contentRendererTransforms.test.ts @@ -1,4 +1,5 @@ import type { components } from '@schema'; +import { parseTipTapDoc } from '@utils/tiptap'; import { buildAllPointingsLeftSections, @@ -7,7 +8,6 @@ import { buildDocumentInit, joinBubblesToDoc, joinPointingsForAnalysis, - parseTipTapDoc, toAnswerNodes, toBubbleNodes, toChatScenario, diff --git a/apps/native/src/features/student/problem/transforms/contentRendererTransforms.ts b/apps/native/src/features/student/problem/transforms/contentRendererTransforms.ts index fd6b42950..80630b569 100644 --- a/apps/native/src/features/student/problem/transforms/contentRendererTransforms.ts +++ b/apps/native/src/features/student/problem/transforms/contentRendererTransforms.ts @@ -15,6 +15,7 @@ import type { } from '@repo/pointer-content-renderer'; import type { components } from '@schema'; +import { parseTipTapDoc } from '@utils/tiptap'; type PointingWithFeedbackResp = components['schemas']['PointingWithFeedbackResp']; type ProblemWithStudyInfoResp = components['schemas']['ProblemWithStudyInfoResp']; @@ -43,32 +44,6 @@ export interface JoinedPointing { parentProblemDisplayNo: string; } -// ── JSON parsing ──────────────────────────────────────────────────── - -const EMPTY_DOC: JSONNode = { type: 'doc', content: [] }; - -/** - * 서버의 JSON string 을 안전하게 `JSONNode` 로 파싱. 실패/빈 입력 시 - * `{ type: 'doc', content: [] }` 반환 (절대 throw 하지 않음). - */ -export function parseTipTapDoc(raw?: string | null): JSONNode { - if (raw == null || raw === '') return EMPTY_DOC; - try { - const parsed: unknown = JSON.parse(raw); - if ( - parsed != null && - typeof parsed === 'object' && - 'type' in parsed && - typeof (parsed as { type: unknown }).type === 'string' - ) { - return parsed as JSONNode; - } - return EMPTY_DOC; - } catch { - return EMPTY_DOC; - } -} - // ── Chat scenario construction ────────────────────────────────────── /** paragraph 이면서 content 가 비었거나 모든 text 가 공백뿐인 경우 true. */ diff --git a/apps/native/src/utils/index.ts b/apps/native/src/utils/index.ts index 1f65c92d4..439b00904 100644 --- a/apps/native/src/utils/index.ts +++ b/apps/native/src/utils/index.ts @@ -2,3 +2,4 @@ export * from './auth'; export * from './date'; export * from './dateFormatter'; export * from './env'; +export * from './tiptap'; diff --git a/apps/native/src/utils/tiptap.ts b/apps/native/src/utils/tiptap.ts new file mode 100644 index 000000000..c4bef8672 --- /dev/null +++ b/apps/native/src/utils/tiptap.ts @@ -0,0 +1,25 @@ +import type { JSONNode } from '@repo/pointer-content-renderer'; + +const EMPTY_DOC: JSONNode = { type: 'doc', content: [] }; + +/** + * 서버의 JSON string 을 안전하게 `JSONNode` 로 파싱. 실패/빈 입력 시 + * `{ type: 'doc', content: [] }` 반환 (절대 throw 하지 않음). + */ +export function parseTipTapDoc(raw?: string | null): JSONNode { + if (raw == null || raw === '') return EMPTY_DOC; + try { + const parsed: unknown = JSON.parse(raw); + if ( + parsed != null && + typeof parsed === 'object' && + 'type' in parsed && + typeof (parsed as { type: unknown }).type === 'string' + ) { + return parsed as JSONNode; + } + return EMPTY_DOC; + } catch { + return EMPTY_DOC; + } +} diff --git a/packages/pointer-content-renderer/dev/dev-panel.ts b/packages/pointer-content-renderer/dev/dev-panel.ts index f8f0a200e..28eb8f3f5 100644 --- a/packages/pointer-content-renderer/dev/dev-panel.ts +++ b/packages/pointer-content-renderer/dev/dev-panel.ts @@ -12,7 +12,6 @@ import { mockChatResumeMidSecond, mockChatResumeAllComplete, mockHomeCards, - mockHomeName, } from './mock-data'; function sendMockMessage(msg: RNToWebViewMessage): void { @@ -121,7 +120,6 @@ function createDevPanel(): void { sendMockMessage({ type: 'init', mode: 'home', - name: mockHomeName, cards: mockHomeCards, }), }, diff --git a/packages/pointer-content-renderer/dev/mock-data.ts b/packages/pointer-content-renderer/dev/mock-data.ts index 1c31b4254..55a51a4e6 100644 --- a/packages/pointer-content-renderer/dev/mock-data.ts +++ b/packages/pointer-content-renderer/dev/mock-data.ts @@ -3292,18 +3292,16 @@ export const mockAllRightSections: OverviewSection[] = [ export const mockHomeCards: HomeCard[] = [ { type: 'comment', - timeRemainingInHours: 24, + title: '테스트님을 위한 1:1 코멘트', + subtitle: '출제진이 직접 작성한 코멘트에요.', + // 24시간 뒤 만료 + expiryAt: Date.now() + 24 * 60 * 60 * 1000, content: { type: 'doc', content: [ paragraph( text( - '출제진이 직접 작성한 내용(Content이 나타나는 영역입니다. LaTex, 수식, Markup이 포함됩니다. ' - ) - ), - paragraph( - text( - '출제진이 직접 작성한 내용(Content이 나타나는 영역입니다. LaTex, 수식, Markup이 포함됩니다. ' + '출제진이 직접 작성한 내용(Content)이 나타나는 영역입니다. LaTex, 수식, Markup이 포함됩니다.' ) ), paragraph( @@ -3313,16 +3311,14 @@ export const mockHomeCards: HomeCard[] = [ inlineMath('f\\left(-\\frac{3}{2}\\right) = -\\frac{29}{4}'), text(' 이다.') ), - paragraph( - text( - '출제진이 직접 작성한 내용(Content이 나타나는 영역입니다. LaTex, 수식, Markup이 포함됩니다.' - ) - ), ], }, }, { type: 'study-summary', + title: '테스트님을 위한 학습 내용 정리', + subtitle: + '테스트님의 학습을 분석해 취약점을 도출했어요.\n지금 바로 출제진의 문제 접근법을 배워봐요.', groups: [ { label: '오늘의 학습', @@ -3330,14 +3326,55 @@ export const mockHomeCards: HomeCard[] = [ items: [ { badges: [{ text: '집중학습', variant: 'orange' }], - headerText: '어드민 헤드라인/권장 15글자/선택', + title: { + type: 'doc', + content: [ + { + type: 'paragraph', + attrs: { textAlign: null }, + content: [ + { type: 'inlineMath', attrs: { latex: 'y=\\sin x' } }, + { text: '의 미분계수', type: 'text' }, + ], + }, + ], + }, + description: { + type: 'doc', + content: [ + { + type: 'paragraph', + attrs: { textAlign: null }, + content: [ + { + type: 'inlineMath', + attrs: { latex: '\\frac{\\mathrm{d}}{\\mathrm{d}x}\\sin x = \\cos x' }, + }, + { text: ' 이므로, ', type: 'text' }, + { type: 'inlineMath', attrs: { latex: '\\sin x' } }, + { text: '의 미분계수는 ', type: 'text' }, + { type: 'inlineMath', attrs: { latex: '\\cos x' } }, + { text: '이다.', type: 'text' }, + ], + }, + ], + }, content: { type: 'doc', content: [ paragraph( - text( - '어드민에 등록된 Title 영역입니다. 권장길이 공백 포함 26~38자, 최대 공백포함 60자.' - ) + text('삼각함수의 도함수는 '), + inlineMath("(\\sin x)' = \\cos x"), + text(', '), + inlineMath("(\\cos x)' = -\\sin x"), + text(' 이다.') + ), + paragraph( + text('예를 들어 '), + inlineMath('y = \\sin(2x)'), + text(' 의 미분계수는 연쇄법칙으로 '), + inlineMath("y' = 2\\cos(2x)"), + text(' 가 된다.') ), ], }, @@ -3350,37 +3387,43 @@ export const mockHomeCards: HomeCard[] = [ items: [ { badges: [{ text: '집중학습', variant: 'green' }], - headerText: '어드민 헤드라인/권장 15글자/선택', - content: { + title: { + type: 'doc', + content: [ + { + type: 'paragraph', + attrs: { textAlign: null }, + content: [ + { type: 'inlineMath', attrs: { latex: '\\int x^n \\,dx' } }, + { text: '의 일반형', type: 'text' }, + ], + }, + ], + }, + description: { type: 'doc', content: [ paragraph( - text( - '어드민에 등록된 Title 영역입니다. 권장길이 공백 포함 26~38자, 최대 공백포함 60자.' - ) - ), - paragraph( - text('함수 '), - inlineMath('g(x) = \\int_0^x f(t) dt'), - text(' 에서 '), - inlineMath("g'(x) = f(x)"), - text(' 이므로, '), - inlineMath("g'(2) = f(2) = 4 + 6 - 5 = 5"), - text(' 이다.') + text('지수가 '), + inlineMath('n \\neq -1'), + text('일 때 부정적분의 일반형을 익혀봐요.') ), ], }, - }, - { - badges: [{ text: '집중학습', variant: 'green' }], - headerText: '어드민 헤드라인/권장 15글자/선택', content: { type: 'doc', content: [ paragraph( - text( - '어드민에 등록된 Title 영역입니다. 권장길이 공백 포함 26~38자, 최대 공백포함 60자.' - ) + inlineMath('\\int x^n \\,dx = \\frac{x^{n+1}}{n+1} + C'), + text(' 단, '), + inlineMath('n \\neq -1'), + text('.') + ), + paragraph( + inlineMath('n = -1'), + text(' 인 경우는 '), + inlineMath('\\int \\frac{1}{x} \\,dx = \\ln |x| + C'), + text(' 가 된다.') ), ], }, @@ -3390,5 +3433,3 @@ export const mockHomeCards: HomeCard[] = [ ], }, ]; - -export const mockHomeName = '테스트'; diff --git a/packages/pointer-content-renderer/src/types.ts b/packages/pointer-content-renderer/src/types.ts index b33052260..b313219d4 100644 --- a/packages/pointer-content-renderer/src/types.ts +++ b/packages/pointer-content-renderer/src/types.ts @@ -46,8 +46,6 @@ export type RNToWebViewMessage = | { type: 'init'; mode: 'home'; - /** 학생 이름 — 카드 타이틀에 삽입 */ - name: string; cards: HomeCard[]; } | { @@ -161,13 +159,21 @@ export type HomeCard = HomeCommentCard | HomeStudySummaryCard; export interface HomeCommentCard { type: 'comment'; - /** 남은 시간 (시). ≤4 이면 빨간색 표시 */ - timeRemainingInHours: number; + /** 카드 헤더 타이틀 (예: "{이름}님을 위한 1:1 코멘트") */ + title: string; + /** 카드 부제 */ + subtitle: string; + /** 만료 시각 (epoch ms). `null` 이면 시간 뱃지를 표시하지 않음. */ + expiryAt: number | null; content: JSONNode; } export interface HomeStudySummaryCard { type: 'study-summary'; + /** 카드 헤더 타이틀 (primary 색상) */ + title: string; + /** 카드 부제. 줄바꿈은 '\n'. */ + subtitle: string; groups: HomeStudyGroup[]; } @@ -180,8 +186,9 @@ export interface HomeStudyGroup { export interface HomeStudyItem { badges: HomeStudyBadge[]; - headerText: string; - /** LaTeX 포함 가능한 TipTap JSON 본문 */ + /** LaTeX 포함 가능한 TipTap JSON */ + title: JSONNode; + description: JSONNode; content: JSONNode; } diff --git a/packages/pointer-content-renderer/src/web/core/styles/base.css b/packages/pointer-content-renderer/src/web/core/styles/base.css index dc7b1f6b8..c5b929f5f 100644 --- a/packages/pointer-content-renderer/src/web/core/styles/base.css +++ b/packages/pointer-content-renderer/src/web/core/styles/base.css @@ -70,6 +70,10 @@ --tt-color-highlight-red: #ffe4e6; --horizontal-rule-color: rgba(37, 39, 45, 0.1); + + /* Motion — iOS-style ease-out for reveal/collapsible interactions */ + --motion-ease: cubic-bezier(0.32, 0.72, 0, 1); + --motion-duration: 350ms; } * { diff --git a/packages/pointer-content-renderer/src/web/main.ts b/packages/pointer-content-renderer/src/web/main.ts index 9b94dafab..d5cb01cc7 100644 --- a/packages/pointer-content-renderer/src/web/main.ts +++ b/packages/pointer-content-renderer/src/web/main.ts @@ -147,7 +147,7 @@ onMessage(async (msg) => { case 'home': { try { - const dispose = await renderHome(container, msg.cards, msg.name, isCurrent); + const dispose = await renderHome(container, msg.cards, isCurrent); if (!isCurrent()) return; activeDispose = dispose; } catch (e) { diff --git a/packages/pointer-content-renderer/src/web/modes/chat/chat.css b/packages/pointer-content-renderer/src/web/modes/chat/chat.css index c615fd84d..415506675 100644 --- a/packages/pointer-content-renderer/src/web/modes/chat/chat.css +++ b/packages/pointer-content-renderer/src/web/modes/chat/chat.css @@ -199,7 +199,7 @@ .chat-expand-content { height: 0; overflow: hidden; - transition: height 350ms cubic-bezier(0.4, 0, 0.2, 1); + transition: height var(--motion-duration) var(--motion-ease); } /* Inner: visual layer with padding, bg, and rounded bottom corners. */ diff --git a/packages/pointer-content-renderer/src/web/modes/home/home-renderer.ts b/packages/pointer-content-renderer/src/web/modes/home/home-renderer.ts index d73ffbbd8..44ab38c02 100644 --- a/packages/pointer-content-renderer/src/web/modes/home/home-renderer.ts +++ b/packages/pointer-content-renderer/src/web/modes/home/home-renderer.ts @@ -31,12 +31,22 @@ function el( return e; } +/** + * 줄바꿈(`\n`) 포함 문자열을 안전하게
로 분리해 element 에 채운다. + */ +function appendMultilineText(parent: HTMLElement, text: string): void { + const lines = text.split('\n'); + lines.forEach((line, idx) => { + if (idx > 0) parent.appendChild(document.createElement('br')); + parent.appendChild(document.createTextNode(line)); + }); +} + // ── 메인 ── export async function renderHome( container: HTMLElement, cards: HomeCard[], - name: string, isCurrent: () => boolean ): Promise<() => void> { container.classList.add('home-mode'); @@ -46,10 +56,10 @@ export async function renderHome( if (!isCurrent()) return () => {}; switch (card.type) { case 'comment': - disposers.push(await renderCommentCard(container, name, card, isCurrent)); + disposers.push(await renderCommentCard(container, card, isCurrent)); break; case 'study-summary': - disposers.push(await renderStudySummaryCard(container, name, card, isCurrent)); + disposers.push(await renderStudySummaryCard(container, card, isCurrent)); break; } } @@ -61,13 +71,10 @@ export async function renderHome( async function renderCommentCard( parent: HTMLElement, - name: string, card: HomeCommentCard, isCurrent: () => boolean ): Promise<() => void> { const root = el('div', 'home-card'); - - // 카드 내부 컨테이너 const inner = el('div', 'home-card-inner'); // ─ 헤더 (title + time) @@ -76,30 +83,29 @@ async function renderCommentCard( const headerLeft = el('div', 'home-card-header-left'); const icon = el('div', 'home-card-header-icon', commentIconSvg); const title = el('div', 'home-card-title'); - title.textContent = `${name}을(를) 위한 1:1 코멘트`; + title.textContent = card.title; headerLeft.append(icon, title); - // 시간 뱃지 - const urgent = card.timeRemainingInHours <= 4; - const timeBadge = el( - 'div', - `home-comment-time${urgent ? ' home-comment-time--urgent' : ''}`, - `${hourglassSvg(urgent)}${card.timeRemainingInHours}h` - ); - - header.append(headerLeft, timeBadge); + // 시간 뱃지 (1분 간격으로 자동 갱신). expiryAt 이 null 이면 뱃지 자체를 생략. + let timeDisposer: () => void = () => {}; + if (card.expiryAt !== null) { + const timeBadge = el('div'); + timeDisposer = setupTimeBadgeTick(timeBadge, card.expiryAt); + header.append(headerLeft, timeBadge); + } else { + header.append(headerLeft); + } // ─ 부제 - const subtitle = el('div', 'home-card-subtitle', '출제진이 직접 작성한 코멘트에요.'); + const subtitle = el('div', 'home-card-subtitle'); + appendMultilineText(subtitle, card.subtitle); // ─ 코멘트 body (gray box) const body = el('div', 'home-comment-body'); const bodyContainer = el('div', 'home-comment-body-container'); const bodyContent = el('div', 'home-comment-body-content'); - // content 렌더 - const contentHtml = serializeJSONToHTML(card.content); - bodyContent.innerHTML = contentHtml; + bodyContent.innerHTML = serializeJSONToHTML(card.content); bodyContainer.appendChild(bodyContent); // fade gradient @@ -118,13 +124,37 @@ async function renderCommentCard( parent.appendChild(root); // math 렌더 - if (!isCurrent()) return () => {}; + if (!isCurrent()) { + timeDisposer(); + return () => {}; + } await renderMath(bodyContent); // ─ collapsible 설정 (렌더 후 높이 비교) - const disposer = setupCommentCollapsible(bodyContainer, fade, toggle); + const collapseDisposer = setupCommentCollapsible(bodyContainer, fade, toggle); - return disposer; + return () => { + timeDisposer(); + collapseDisposer(); + }; +} + +/** + * 만료 시각으로부터 남은 시간(시간 단위, 올림)을 계산해 뱃지를 갱신. + * 1분 간격으로 자동 갱신. urgent (≤4h) 시 빨간색. + */ +function setupTimeBadgeTick(badge: HTMLElement, expiryAt: number): () => void { + const update = () => { + const now = Date.now(); + const remainingMs = Math.max(0, expiryAt - now); + const remainingHours = Math.ceil(remainingMs / (1000 * 60 * 60)); + const urgent = remainingHours > 0 && remainingHours <= 4; + badge.className = `home-comment-time${urgent ? ' home-comment-time--urgent' : ''}`; + badge.innerHTML = `${hourglassSvg(urgent)}${remainingHours}h`; + }; + update(); + const interval = setInterval(update, 60_000); + return () => clearInterval(interval); } function setupCommentCollapsible( @@ -187,7 +217,6 @@ function setupCommentCollapsible( async function renderStudySummaryCard( parent: HTMLElement, - name: string, card: HomeStudySummaryCard, isCurrent: () => boolean ): Promise<() => void> { @@ -199,17 +228,13 @@ async function renderStudySummaryCard( const headerLeft = el('div', 'home-card-header-left'); const icon = el('div', 'home-card-header-icon', studyIconSvg); const title = el('div', 'home-card-title home-card-title--primary'); - title.textContent = `${name}에게 꼭 필요한 학습 내용 정리`; + title.textContent = card.title; headerLeft.append(icon, title); header.appendChild(headerLeft); // ─ 설명 const desc = el('div', 'home-card-subtitle'); - desc.append( - document.createTextNode(`${name}님의 학습을 분석해 취약점을 도출했어요.`), - document.createElement('br'), - document.createTextNode('지금 바로 출제진의 문제 접근법을 배워봐요.') - ); + appendMultilineText(desc, card.subtitle); // ─ groups const groupsContainer = el('div', 'home-study-groups'); @@ -277,7 +302,7 @@ async function renderStudyItem( ): Promise<() => void> { const root = el('div', 'home-study-item'); - // ─ 헤더 (badges + headline + title) + // ─ 헤더 (badges + title + description) const headerSection = el('div', 'home-study-item-header'); const headline = el('div', 'home-study-item-headline'); @@ -287,66 +312,78 @@ async function renderStudyItem( headline.appendChild(b); } - if (item.headerText) { - const headlineText = el('div', 'home-study-item-headline-text'); - headlineText.textContent = item.headerText; - headline.appendChild(headlineText); + // title — 빈 doc이면 element 자체를 생략 + const titleHtml = serializeJSONToHTML(item.title); + let titleEl: HTMLElement | null = null; + if (titleHtml.trim()) { + titleEl = el('div', 'home-study-item-title'); + titleEl.innerHTML = titleHtml; + headline.appendChild(titleEl); } - const title = el('div', 'home-study-item-title'); - // title은 content의 첫 paragraph 또는 전체를 사용 - const titleHtml = serializeJSONToHTML(item.content); - title.innerHTML = titleHtml; + headerSection.appendChild(headline); - headerSection.append(headline, title); + // description — 빈 doc이면 element 자체를 생략 + const descHtml = serializeJSONToHTML(item.description); + let descEl: HTMLElement | null = null; + if (descHtml.trim()) { + descEl = el('div', 'home-study-item-description'); + descEl.innerHTML = descHtml; + headerSection.appendChild(descEl); + } - // ─ 펼칠 콘텐츠 (LaTeX 포함) - const contentEl = el('div', 'home-study-item-content home-study-item-content--collapsed'); - const contentHtml = serializeJSONToHTML(item.content); - contentEl.innerHTML = contentHtml; + // ─ 펼칠 콘텐츠 (LaTeX 포함) — outer(height transition) + inner(padding 고정) 분리 + const contentOuter = el('div', 'home-study-item-content'); + const contentInner = el('div', 'home-study-item-content-inner'); + contentInner.innerHTML = serializeJSONToHTML(item.content); + contentOuter.appendChild(contentInner); // ─ 토글 버튼 const toggle = el('button', 'home-collapsible-toggle', `펼치기${chevronDownSvg}`); - root.append(headerSection, contentEl, toggle); + root.append(headerSection, contentOuter, toggle); parent.appendChild(root); // math 렌더 if (!isCurrent()) return () => {}; - await renderMath(title); - await renderMath(contentEl); + if (titleEl) await renderMath(titleEl); + if (descEl) await renderMath(descEl); + await renderMath(contentInner); // ─ collapsible 설정 - const disposer = setupStudyItemCollapsible(contentEl, toggle); - return disposer; + return setupStudyItemCollapsible(contentOuter, contentInner, toggle); } -function setupStudyItemCollapsible(contentEl: HTMLElement, toggle: HTMLButtonElement): () => void { +/** + * Height-기반 collapsible. outer 는 `height: 0` 시작 + transition, + * inner 는 padding 고정. 펼침 후 `height: auto` 로 해제해 동적 컨텐츠 변화 자동 대응. + */ +function setupStudyItemCollapsible( + outer: HTMLElement, + inner: HTMLElement, + toggle: HTMLButtonElement +): () => void { let isOpen = false; const handler = () => { if (isOpen) { - // 접기 - contentEl.style.maxHeight = `${contentEl.scrollHeight}px`; + // 접기: 현재 높이 명시 → 다음 프레임에 0 → transition + outer.style.height = `${inner.scrollHeight}px`; requestAnimationFrame(() => { - contentEl.style.maxHeight = '0'; - contentEl.classList.remove('home-study-item-content--expanded'); - contentEl.classList.add('home-study-item-content--collapsed'); + outer.style.height = '0'; toggle.innerHTML = `펼치기${chevronDownSvg}`; }); } else { - // 펼치기 - contentEl.classList.remove('home-study-item-content--collapsed'); - contentEl.classList.add('home-study-item-content--expanded'); - const fullHeight = contentEl.scrollHeight; - contentEl.style.maxHeight = `${fullHeight}px`; + // 펼치기: 측정된 높이로 set → transitionend 후 'auto' 로 해제 + outer.style.height = `${inner.scrollHeight}px`; toggle.innerHTML = `접기${chevronUpSvg}`; - const onEnd = () => { - contentEl.style.maxHeight = ''; - contentEl.removeEventListener('transitionend', onEnd); + const onEnd = (e: TransitionEvent) => { + if (e.propertyName !== 'height') return; + outer.removeEventListener('transitionend', onEnd); + outer.style.height = 'auto'; }; - contentEl.addEventListener('transitionend', onEnd, { once: true }); + outer.addEventListener('transitionend', onEnd); } isOpen = !isOpen; }; diff --git a/packages/pointer-content-renderer/src/web/modes/home/home.css b/packages/pointer-content-renderer/src/web/modes/home/home.css index a271cc9e4..ad1bcab45 100644 --- a/packages/pointer-content-renderer/src/web/modes/home/home.css +++ b/packages/pointer-content-renderer/src/web/modes/home/home.css @@ -107,7 +107,7 @@ padding: 16px 16px 8px 16px; position: relative; overflow: hidden; - transition: max-height 300ms ease; + transition: max-height var(--motion-duration) var(--motion-ease); } .home-comment-body-content { @@ -132,7 +132,7 @@ height: 32px; background: linear-gradient(to bottom, rgba(248, 249, 252, 0), rgba(248, 249, 252, 1)); pointer-events: none; - transition: opacity 300ms ease; + transition: opacity var(--motion-duration) var(--motion-ease); } .home-comment-fade--hidden { @@ -290,7 +290,7 @@ letter-spacing: 0.13px; white-space: nowrap; flex-shrink: 0; - align-self: stretch; + align-self: center; } .home-study-badge--orange { @@ -314,7 +314,7 @@ color: #0055cc; } -.home-study-item-headline-text { +.home-study-item-title { font-size: 13px; font-weight: 500; line-height: 20px; @@ -325,29 +325,22 @@ align-items: center; } -.home-study-item-title { +.home-study-item-description { font-size: 16px; font-weight: 700; line-height: 26px; color: var(--color-gray-900); } -/* 펼친 콘텐츠: 17px/24px, gray-800 */ +/* Outer: height-transitioning shell. padding/bg 없음 → height: 0 으로 완전 접힘. */ .home-study-item-content { - color: var(--color-gray-800); - padding: 0 16px; + height: 0; overflow: hidden; - transition: max-height 300ms ease; + transition: height var(--motion-duration) var(--motion-ease); } -.home-study-item-content--collapsed { - max-height: 0; - padding-top: 0; - padding-bottom: 0; -} - -.home-study-item-content--expanded { - max-height: none; - padding-top: 8px; - padding-bottom: 8px; +/* Inner: padding 고정. transition 영향 없음 → padding 점프 없음. */ +.home-study-item-content-inner { + padding: 8px 16px; + color: var(--color-gray-800); }