From e4287b16a377928765c57a36e55be08839c0b0a8 Mon Sep 17 00:00:00 2001 From: sterdsterd Date: Tue, 12 May 2026 22:19:05 +0900 Subject: [PATCH 1/7] refactor(native, renderer): replace headerText with title/description Updated HomeStudyItem to use separate title and description fields instead of headerText. Adjusted rendering logic and mock data to support the new structure. --- .../home/transforms/homeContentTransforms.ts | 3 +- .../pointer-content-renderer/dev/mock-data.ts | 191 ++++++++++++++++-- .../pointer-content-renderer/src/types.ts | 5 +- .../src/web/modes/home/home-renderer.ts | 17 +- .../src/web/modes/home/home.css | 2 +- 5 files changed, 186 insertions(+), 32 deletions(-) diff --git a/apps/native/src/features/student/home/transforms/homeContentTransforms.ts b/apps/native/src/features/student/home/transforms/homeContentTransforms.ts index 5340112c..03795867 100644 --- a/apps/native/src/features/student/home/transforms/homeContentTransforms.ts +++ b/apps/native/src/features/student/home/transforms/homeContentTransforms.ts @@ -55,7 +55,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), }; diff --git a/packages/pointer-content-renderer/dev/mock-data.ts b/packages/pointer-content-renderer/dev/mock-data.ts index 1c31b425..a972877b 100644 --- a/packages/pointer-content-renderer/dev/mock-data.ts +++ b/packages/pointer-content-renderer/dev/mock-data.ts @@ -104,7 +104,7 @@ export const mockDocumentContent: JSONNode = { { type: 'inlineMath', attrs: { - latex: 'g(x) = \\begin{cases} x & (x \\neq 1) \\\\ a & (x = 1) \\end{cases}', + latex: 'g(x) = \\begin{cases} x & (x \\neq 1) \\ a & (x = 1) \\end{cases}', }, }, { @@ -3330,15 +3330,153 @@ 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자.' - ) - ), + { + type: 'paragraph', + attrs: { textAlign: null }, + content: [ + { + type: 'inlineMath', + attrs: { + latex: + '\\left| \\frac{ \\displaystyle \\sum_{k=1}^{n} \\left( \\frac{k^2-3k+2}{k^2+k} - \\frac{k-1}{k+1} \\right) }{ \\displaystyle \\sqrt{ \\left( \\sum_{k=1}^{n} k \\right)^2 - \\sum_{k=1}^{n} k^2 } } \\right| = \\frac{ \\displaystyle \\left| \\sum_{k=1}^{n} \\frac{2(1-k)}{k(k+1)} \\right| }{ \\displaystyle \\sqrt{ \\frac{n^2(n+1)^2}{4} - \\frac{n(n+1)(2n+1)}{6} } }', + }, + }, + { text: ' ', type: 'text' }, + { + type: 'inlineMath', + attrs: { + latex: + '\\int_{0}^{2} \\left| x^3-3x^2+2x \\right|\\,dx = \\int_{0}^{1} \\left(x^3-3x^2+2x\\right)\\,dx - \\int_{1}^{2} \\left(x^3-3x^2+2x\\right)\\,dx', + }, + }, + { text: ' ', type: 'text' }, + { + type: 'inlineMath', + attrs: { + latex: + '\\left\\{ \\begin{array}{ll} \\displaystyle f(x)=x^2-2ax+a^2-1, & xa \\\\[0.8em] \\displaystyle \\lim_{x\\to a-}f(x) = \\lim_{x\\to a+}f(x) = f(a) \\end{array} \\right.', + }, + }, + { text: ' ', type: 'text' }, + { + type: 'inlineMath', + attrs: { + latex: + '\\frac{ \\displaystyle \\binom{n}{0} - 2\\binom{n}{1} + 3\\binom{n}{2} - \\cdots + (-1)^n(n+1)\\binom{n}{n} }{ \\displaystyle \\sum_{k=0}^{n} \\binom{n}{k}2^{n-k}(-1)^k } = \\frac{ \\displaystyle \\sum_{k=0}^{n} (-1)^k(k+1)\\binom{n}{k} }{ \\displaystyle (2-1)^n }', + }, + }, + { text: ' ', type: 'text' }, + { + type: 'inlineMath', + attrs: { + latex: + '\\left( \\frac{ \\displaystyle \\sqrt{x+2\\sqrt{x-1}} - \\sqrt{x-2\\sqrt{x-1}} }{ \\displaystyle \\sqrt{x+2\\sqrt{x-1}} + \\sqrt{x-2\\sqrt{x-1}} } \\right)^2 = \\frac{ \\displaystyle x-\\sqrt{x^2-4x+4} }{ \\displaystyle x+\\sqrt{x^2-4x+4} }', + }, + }, + { text: ' ', type: 'text' }, + { + type: 'inlineMath', + attrs: { + latex: + '\\sum_{k=1}^{n} \\frac{ \\displaystyle 1 }{ \\displaystyle \\sqrt{k+1}+\\sqrt{k} } = \\sum_{k=1}^{n} \\left( \\sqrt{k+1}-\\sqrt{k} \\right) = \\sqrt{n+1}-1', + }, + }, + { text: ' ', type: 'text' }, + { + type: 'inlineMath', + attrs: { + latex: + '\\left\\{ \\begin{array}{rcl} \\displaystyle a_{n+1} &=& \\displaystyle \\frac{ 2a_n+3 }{ a_n+2 } \\\\[1em] \\displaystyle b_n &=& \\displaystyle \\frac{ a_n-3 }{ a_n+1 } \\\\[1em] \\displaystyle b_{n+1} &=& \\displaystyle -\\frac{1}{4}b_n \\end{array} \\right.', + }, + }, + { text: ' ', type: 'text' }, + { + type: 'inlineMath', + attrs: { + latex: + '\\left[ \\begin{array}{c} \\displaystyle \\lim_{x\\to 0} \\frac{ \\sqrt{1+ax}-\\sqrt{1-bx} }{ x } \\\\[1.2em] \\displaystyle = \\lim_{x\\to 0} \\frac{ (a+b)x }{ x\\left(\\sqrt{1+ax}+\\sqrt{1-bx}\\right) } \\\\[1.2em] \\displaystyle = \\frac{a+b}{2} \\end{array} \\right]', + }, + }, + { text: ' ', type: 'text' }, + { + type: 'inlineMath', + attrs: { + latex: + '\\left| \\begin{array}{cc} \\displaystyle \\frac{x^2-1}{x-1} & \\displaystyle \\sqrt{x^2-2x+1} \\\\[1em] \\displaystyle \\int_{0}^{x}(t^2-2t+1)\\,dt & \\displaystyle \\sum_{k=1}^{x} \\left(2k-1\\right) \\end{array} \\right| = \\left| \\begin{array}{cc} \\displaystyle x+1 & \\displaystyle |x-1| \\\\[1em] \\displaystyle \\frac{x^3}{3}-x^2+x & \\displaystyle x^2 \\end{array} \\right|', + }, + }, + { text: ' ', type: 'text' }, + { + type: 'inlineMath', + attrs: { + latex: + '\\frac{ \\displaystyle \\int_{0}^{1} \\left( ax^2+bx+c \\right)\\,dx }{ \\displaystyle \\int_{0}^{1} \\left( x^2+x+1 \\right)\\,dx } = \\frac{ \\displaystyle \\frac{a}{3}+\\frac{b}{2}+c }{ \\displaystyle \\frac{11}{6} } = \\frac{2a+3b+6c}{11}', + }, + }, + { text: ' ', type: 'text' }, + { + type: 'inlineMath', + attrs: { + latex: + "\\left\\{ \\begin{array}{l} \\displaystyle f(x)=x^3-3ax^2+\\left(a^2+2a\\right)x-1 \\\\[0.8em] \\displaystyle f\'(x)=3x^2-6ax+\\left(a^2+2a\\right) \\\\[0.8em] \\displaystyle f\'(\\alpha)=0,\\quad f\'(\\beta)=0,\\quad \\alpha<\\beta \\\\[0.8em] \\displaystyle \\int_{\\alpha}^{\\beta} f\'(x)\\,dx = f(\\beta)-f(\\alpha) \\\\[0.8em] \\displaystyle \\left|f(\\alpha)\\right|+\\left|f(\\beta)\\right|=10 \\end{array} \\right.", + }, + }, + { text: ' ', type: 'text' }, + { + type: 'inlineMath', + attrs: { + latex: + '\\left\\{ \\begin{array}{l} \\displaystyle a_1=1,\\qquad a_{n+1}=a_n+2n+1 \\\\[0.8em] \\displaystyle S_n=\\sum_{k=1}^{n}a_k \\\\[0.8em] \\displaystyle S_n = \\sum_{k=1}^{n} \\left( 1+\\sum_{j=1}^{k-1}(2j+1) \\right) \\\\[0.8em] \\displaystyle = \\sum_{k=1}^{n}k^2 = \\frac{n(n+1)(2n+1)}{6} \\end{array} \\right.', + }, + }, + { text: ' ', type: 'text' }, + { + type: 'inlineMath', + attrs: { + latex: + '\\left\\{ \\begin{array}{l} \\displaystyle g(x)= \\left| x^2-4x+3 \\right| \\\\[0.8em] \\displaystyle \\int_{0}^{4}g(x)\\,dx = \\int_{0}^{1}(x^2-4x+3)\\,dx - \\int_{1}^{3}(x^2-4x+3)\\,dx + \\int_{3}^{4}(x^2-4x+3)\\,dx \\\\[0.8em] \\displaystyle = \\left[\\frac{x^3}{3}-2x^2+3x\\right]_{0}^{1} - \\left[\\frac{x^3}{3}-2x^2+3x\\right]_{1}^{3} + \\left[\\frac{x^3}{3}-2x^2+3x\\right]_{3}^{4} \\end{array} \\right.', + }, + }, + ], + }, ], }, }, @@ -3350,8 +3488,11 @@ export const mockHomeCards: HomeCard[] = [ items: [ { badges: [{ text: '집중학습', variant: 'green' }], - headerText: '어드민 헤드라인/권장 15글자/선택', - content: { + title: { + type: 'doc', + content: [paragraph(text('어드민 헤드라인/권장 15글자/선택'))], + }, + description: { type: 'doc', content: [ paragraph( @@ -3359,22 +3500,26 @@ export const mockHomeCards: HomeCard[] = [ '어드민에 등록된 Title 영역입니다. 권장길이 공백 포함 26~38자, 최대 공백포함 60자.' ) ), + ], + }, + content: { + type: 'doc', + content: [ 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( + 'asdfasdfasdfsadfasdfasdfaasdfasdfasdfasdfsadfsdfsdfsdfsdfsdfdsfdsfsdfsdfsdfsdfdssdfsdfsdfasdfasdfasdfsadfasdfasdfaasdfasdfasdfasdfsadfsdfsdfsdfsdfsdfdsfdsfsdfsdfsdfsdfdssdfsdfsdfasdfasdfasdfsadfasdfasdfaasdfasdfasdfasdfsadfsdfsdfsdfsdfsdfdsfdsfsdfsdfsdfsdfdssdfsdfsdf' + ) ), ], }, }, { badges: [{ text: '집중학습', variant: 'green' }], - headerText: '어드민 헤드라인/권장 15글자/선택', - content: { + title: { + type: 'doc', + content: [paragraph(text('어드민 헤드라인/권장 15글자/선택'))], + }, + description: { type: 'doc', content: [ paragraph( @@ -3384,6 +3529,16 @@ export const mockHomeCards: HomeCard[] = [ ), ], }, + content: { + type: 'doc', + content: [ + paragraph( + text( + 'asdfasdfasdfsadfasdfasdfaasdfasdfasdfasdfsadfsdfsdfsdfsdfsdfdsfdsfsdfsdfsdfsdfdssdfsdfsdfasdfasdfasdfsadfasdfasdfaasdfasdfasdfasdfsadfsdfsdfsdfsdfsdfdsfdsfsdfsdfsdfsdfdssdfsdfsdfasdfasdfasdfsadfasdfasdfaasdfasdfasdfasdfsadfsdfsdfsdfsdfsdfdsfdsfsdfsdfsdfsdfdssdfsdfsdf' + ) + ), + ], + }, }, ], }, diff --git a/packages/pointer-content-renderer/src/types.ts b/packages/pointer-content-renderer/src/types.ts index b3305226..d5c76a44 100644 --- a/packages/pointer-content-renderer/src/types.ts +++ b/packages/pointer-content-renderer/src/types.ts @@ -180,8 +180,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/modes/home/home-renderer.ts b/packages/pointer-content-renderer/src/web/modes/home/home-renderer.ts index d73ffbbd..8fdfccba 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 @@ -287,18 +287,14 @@ 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); - } + const title = el('div', 'home-study-item-headline-text'); + title.innerHTML = serializeJSONToHTML(item.title); + headline.appendChild(title); - const title = el('div', 'home-study-item-title'); - // title은 content의 첫 paragraph 또는 전체를 사용 - const titleHtml = serializeJSONToHTML(item.content); - title.innerHTML = titleHtml; + const description = el('div', 'home-study-item-title'); + description.innerHTML = serializeJSONToHTML(item.description); - headerSection.append(headline, title); + headerSection.append(headline, description); // ─ 펼칠 콘텐츠 (LaTeX 포함) const contentEl = el('div', 'home-study-item-content home-study-item-content--collapsed'); @@ -314,6 +310,7 @@ async function renderStudyItem( // math 렌더 if (!isCurrent()) return () => {}; await renderMath(title); + await renderMath(description); await renderMath(contentEl); // ─ collapsible 설정 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 a271cc9e..f35785f1 100644 --- a/packages/pointer-content-renderer/src/web/modes/home/home.css +++ b/packages/pointer-content-renderer/src/web/modes/home/home.css @@ -290,7 +290,7 @@ letter-spacing: 0.13px; white-space: nowrap; flex-shrink: 0; - align-self: stretch; + align-self: center; } .home-study-badge--orange { From ae86f050e613956f0b84c8c022aff485b55f23cd Mon Sep 17 00:00:00 2001 From: sterdsterd Date: Tue, 12 May 2026 22:29:58 +0900 Subject: [PATCH 2/7] feat(native): combine today's and tomorrow's focus cards on HomeScreen --- .../student/home/screens/HomeScreen.tsx | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/apps/native/src/features/student/home/screens/HomeScreen.tsx b/apps/native/src/features/student/home/screens/HomeScreen.tsx index d1c9007a..0138022a 100644 --- a/apps/native/src/features/student/home/screens/HomeScreen.tsx +++ b/apps/native/src/features/student/home/screens/HomeScreen.tsx @@ -34,9 +34,25 @@ const HomeScreen = () => { }); // ── 홈 카드 API ── - const todayStr = formatDateKey(new Date()); + const today = new Date(); + const tomorrow = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 1); + const todayStr = formatDateKey(today); + const tomorrowStr = formatDateKey(tomorrow); const { data: dailyComments } = useGetDailyComments(todayStr); - const { data: focusCards } = useGetFocusCards(todayStr); + const { data: todayFocusCards } = useGetFocusCards(todayStr); + const { data: tomorrowFocusCards } = useGetFocusCards(tomorrowStr); + + const focusCards = useMemo(() => { + if (!todayFocusCards && !tomorrowFocusCards) return null; + const todayData = todayFocusCards?.data ?? []; + const tomorrowData = tomorrowFocusCards?.data ?? []; + const data = [...todayData, ...tomorrowData]; + return { + requestId: todayFocusCards?.requestId ?? tomorrowFocusCards?.requestId ?? '', + total: data.length, + data, + }; + }, [todayFocusCards, tomorrowFocusCards]); const homeInit = useMemo(() => { if (!studentName) return null; From 79a5beb2eb3487e838ebe7f9e2f01c072a887641 Mon Sep 17 00:00:00 2001 From: sterdsterd Date: Wed, 13 May 2026 01:18:44 +0900 Subject: [PATCH 3/7] refactor(native): adopt queryOptions factory pattern for daily-comment and focus-card hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 계층적 queryKey (`['student', 'daily-comment', { commentDate }]`) 구조로 도메인 단위 invalidation 가능. useGetDailyComments / useGetFocusCards 시그니처 유지. --- .../controller/student/dailyComment/index.ts | 1 + .../student/dailyComment/queries.ts | 24 +++++++++++++++++ .../dailyComment/useGetDailyComments.ts | 13 ++------- .../controller/student/focusCard/index.ts | 1 + .../controller/student/focusCard/queries.ts | 27 +++++++++++++++++++ .../student/focusCard/useGetFocusCards.ts | 13 ++------- 6 files changed, 57 insertions(+), 22 deletions(-) create mode 100644 apps/native/src/apis/controller/student/dailyComment/queries.ts create mode 100644 apps/native/src/apis/controller/student/focusCard/queries.ts diff --git a/apps/native/src/apis/controller/student/dailyComment/index.ts b/apps/native/src/apis/controller/student/dailyComment/index.ts index b0c37a6d..b312597d 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 00000000..5d44161b --- /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 ecdd204f..4c1d797e 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 04788653..33b24db6 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 00000000..33b97faa --- /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 0b7cf211..6bb2fed5 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; From 944f8f52b3c15b6790865cbcf0a5f453c77c20e4 Mon Sep 17 00:00:00 2001 From: sterdsterd Date: Wed, 13 May 2026 01:18:50 +0900 Subject: [PATCH 4/7] refactor(native): move parseTipTapDoc to shared @utils/tiptap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit problem transforms 내부에 있던 util을 @utils/tiptap 으로 이동. home transform 등 다른 feature에서도 import 가능하게 됨. --- .../contentRendererTransforms.test.ts | 2 +- .../transforms/contentRendererTransforms.ts | 27 +------------------ apps/native/src/utils/index.ts | 1 + apps/native/src/utils/tiptap.ts | 25 +++++++++++++++++ 4 files changed, 28 insertions(+), 27 deletions(-) create mode 100644 apps/native/src/utils/tiptap.ts 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 3b2088cb..4b0a01eb 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 fd6b4295..80630b56 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 1f65c92d..439b0090 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 00000000..c4bef867 --- /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; + } +} From 4f90c642df626f607f9c768b6ae7cf4ee169764b Mon Sep 17 00:00:00 2001 From: sterdsterd Date: Wed, 13 May 2026 01:18:58 +0900 Subject: [PATCH 5/7] refactor(content-renderer): remove name from home init, add title/subtitle/expiryAt to cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HomeCommentCard.timeRemainingInHours → expiryAt(epoch ms | null) 로 교체 — 렌더러가 직접 시간을 계산·갱신. name 필드 제거 → native에서 카드 생성 시 title/subtitle 미리 조립. mock-data LaTeX 백슬래시 복원 및 mock 구조를 새 스키마에 맞게 정비. --- .../pointer-content-renderer/dev/dev-panel.ts | 2 - .../pointer-content-renderer/dev/mock-data.ts | 206 ++++-------------- .../pointer-content-renderer/src/types.ts | 14 +- .../pointer-content-renderer/src/web/main.ts | 2 +- 4 files changed, 57 insertions(+), 167 deletions(-) diff --git a/packages/pointer-content-renderer/dev/dev-panel.ts b/packages/pointer-content-renderer/dev/dev-panel.ts index f8f0a200..28eb8f3f 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 a972877b..55a51a4e 100644 --- a/packages/pointer-content-renderer/dev/mock-data.ts +++ b/packages/pointer-content-renderer/dev/mock-data.ts @@ -104,7 +104,7 @@ export const mockDocumentContent: JSONNode = { { type: 'inlineMath', attrs: { - latex: 'g(x) = \\begin{cases} x & (x \\neq 1) \\ a & (x = 1) \\end{cases}', + latex: 'g(x) = \\begin{cases} x & (x \\neq 1) \\\\ a & (x = 1) \\end{cases}', }, }, { @@ -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: '오늘의 학습', @@ -3352,11 +3348,9 @@ export const mockHomeCards: HomeCard[] = [ content: [ { type: 'inlineMath', - attrs: { - latex: '\\frac{\\mathrm{d}}{\\mathrm{d}x}\\sin x = \\cos x', - }, + attrs: { latex: '\\frac{\\mathrm{d}}{\\mathrm{d}x}\\sin x = \\cos x' }, }, - { text: ' 이므로, ', type: 'text' }, + { text: ' 이므로, ', type: 'text' }, { type: 'inlineMath', attrs: { latex: '\\sin x' } }, { text: '의 미분계수는 ', type: 'text' }, { type: 'inlineMath', attrs: { latex: '\\cos x' } }, @@ -3368,115 +3362,20 @@ export const mockHomeCards: HomeCard[] = [ content: { type: 'doc', content: [ - { - type: 'paragraph', - attrs: { textAlign: null }, - content: [ - { - type: 'inlineMath', - attrs: { - latex: - '\\left| \\frac{ \\displaystyle \\sum_{k=1}^{n} \\left( \\frac{k^2-3k+2}{k^2+k} - \\frac{k-1}{k+1} \\right) }{ \\displaystyle \\sqrt{ \\left( \\sum_{k=1}^{n} k \\right)^2 - \\sum_{k=1}^{n} k^2 } } \\right| = \\frac{ \\displaystyle \\left| \\sum_{k=1}^{n} \\frac{2(1-k)}{k(k+1)} \\right| }{ \\displaystyle \\sqrt{ \\frac{n^2(n+1)^2}{4} - \\frac{n(n+1)(2n+1)}{6} } }', - }, - }, - { text: ' ', type: 'text' }, - { - type: 'inlineMath', - attrs: { - latex: - '\\int_{0}^{2} \\left| x^3-3x^2+2x \\right|\\,dx = \\int_{0}^{1} \\left(x^3-3x^2+2x\\right)\\,dx - \\int_{1}^{2} \\left(x^3-3x^2+2x\\right)\\,dx', - }, - }, - { text: ' ', type: 'text' }, - { - type: 'inlineMath', - attrs: { - latex: - '\\left\\{ \\begin{array}{ll} \\displaystyle f(x)=x^2-2ax+a^2-1, & xa \\\\[0.8em] \\displaystyle \\lim_{x\\to a-}f(x) = \\lim_{x\\to a+}f(x) = f(a) \\end{array} \\right.', - }, - }, - { text: ' ', type: 'text' }, - { - type: 'inlineMath', - attrs: { - latex: - '\\frac{ \\displaystyle \\binom{n}{0} - 2\\binom{n}{1} + 3\\binom{n}{2} - \\cdots + (-1)^n(n+1)\\binom{n}{n} }{ \\displaystyle \\sum_{k=0}^{n} \\binom{n}{k}2^{n-k}(-1)^k } = \\frac{ \\displaystyle \\sum_{k=0}^{n} (-1)^k(k+1)\\binom{n}{k} }{ \\displaystyle (2-1)^n }', - }, - }, - { text: ' ', type: 'text' }, - { - type: 'inlineMath', - attrs: { - latex: - '\\left( \\frac{ \\displaystyle \\sqrt{x+2\\sqrt{x-1}} - \\sqrt{x-2\\sqrt{x-1}} }{ \\displaystyle \\sqrt{x+2\\sqrt{x-1}} + \\sqrt{x-2\\sqrt{x-1}} } \\right)^2 = \\frac{ \\displaystyle x-\\sqrt{x^2-4x+4} }{ \\displaystyle x+\\sqrt{x^2-4x+4} }', - }, - }, - { text: ' ', type: 'text' }, - { - type: 'inlineMath', - attrs: { - latex: - '\\sum_{k=1}^{n} \\frac{ \\displaystyle 1 }{ \\displaystyle \\sqrt{k+1}+\\sqrt{k} } = \\sum_{k=1}^{n} \\left( \\sqrt{k+1}-\\sqrt{k} \\right) = \\sqrt{n+1}-1', - }, - }, - { text: ' ', type: 'text' }, - { - type: 'inlineMath', - attrs: { - latex: - '\\left\\{ \\begin{array}{rcl} \\displaystyle a_{n+1} &=& \\displaystyle \\frac{ 2a_n+3 }{ a_n+2 } \\\\[1em] \\displaystyle b_n &=& \\displaystyle \\frac{ a_n-3 }{ a_n+1 } \\\\[1em] \\displaystyle b_{n+1} &=& \\displaystyle -\\frac{1}{4}b_n \\end{array} \\right.', - }, - }, - { text: ' ', type: 'text' }, - { - type: 'inlineMath', - attrs: { - latex: - '\\left[ \\begin{array}{c} \\displaystyle \\lim_{x\\to 0} \\frac{ \\sqrt{1+ax}-\\sqrt{1-bx} }{ x } \\\\[1.2em] \\displaystyle = \\lim_{x\\to 0} \\frac{ (a+b)x }{ x\\left(\\sqrt{1+ax}+\\sqrt{1-bx}\\right) } \\\\[1.2em] \\displaystyle = \\frac{a+b}{2} \\end{array} \\right]', - }, - }, - { text: ' ', type: 'text' }, - { - type: 'inlineMath', - attrs: { - latex: - '\\left| \\begin{array}{cc} \\displaystyle \\frac{x^2-1}{x-1} & \\displaystyle \\sqrt{x^2-2x+1} \\\\[1em] \\displaystyle \\int_{0}^{x}(t^2-2t+1)\\,dt & \\displaystyle \\sum_{k=1}^{x} \\left(2k-1\\right) \\end{array} \\right| = \\left| \\begin{array}{cc} \\displaystyle x+1 & \\displaystyle |x-1| \\\\[1em] \\displaystyle \\frac{x^3}{3}-x^2+x & \\displaystyle x^2 \\end{array} \\right|', - }, - }, - { text: ' ', type: 'text' }, - { - type: 'inlineMath', - attrs: { - latex: - '\\frac{ \\displaystyle \\int_{0}^{1} \\left( ax^2+bx+c \\right)\\,dx }{ \\displaystyle \\int_{0}^{1} \\left( x^2+x+1 \\right)\\,dx } = \\frac{ \\displaystyle \\frac{a}{3}+\\frac{b}{2}+c }{ \\displaystyle \\frac{11}{6} } = \\frac{2a+3b+6c}{11}', - }, - }, - { text: ' ', type: 'text' }, - { - type: 'inlineMath', - attrs: { - latex: - "\\left\\{ \\begin{array}{l} \\displaystyle f(x)=x^3-3ax^2+\\left(a^2+2a\\right)x-1 \\\\[0.8em] \\displaystyle f\'(x)=3x^2-6ax+\\left(a^2+2a\\right) \\\\[0.8em] \\displaystyle f\'(\\alpha)=0,\\quad f\'(\\beta)=0,\\quad \\alpha<\\beta \\\\[0.8em] \\displaystyle \\int_{\\alpha}^{\\beta} f\'(x)\\,dx = f(\\beta)-f(\\alpha) \\\\[0.8em] \\displaystyle \\left|f(\\alpha)\\right|+\\left|f(\\beta)\\right|=10 \\end{array} \\right.", - }, - }, - { text: ' ', type: 'text' }, - { - type: 'inlineMath', - attrs: { - latex: - '\\left\\{ \\begin{array}{l} \\displaystyle a_1=1,\\qquad a_{n+1}=a_n+2n+1 \\\\[0.8em] \\displaystyle S_n=\\sum_{k=1}^{n}a_k \\\\[0.8em] \\displaystyle S_n = \\sum_{k=1}^{n} \\left( 1+\\sum_{j=1}^{k-1}(2j+1) \\right) \\\\[0.8em] \\displaystyle = \\sum_{k=1}^{n}k^2 = \\frac{n(n+1)(2n+1)}{6} \\end{array} \\right.', - }, - }, - { text: ' ', type: 'text' }, - { - type: 'inlineMath', - attrs: { - latex: - '\\left\\{ \\begin{array}{l} \\displaystyle g(x)= \\left| x^2-4x+3 \\right| \\\\[0.8em] \\displaystyle \\int_{0}^{4}g(x)\\,dx = \\int_{0}^{1}(x^2-4x+3)\\,dx - \\int_{1}^{3}(x^2-4x+3)\\,dx + \\int_{3}^{4}(x^2-4x+3)\\,dx \\\\[0.8em] \\displaystyle = \\left[\\frac{x^3}{3}-2x^2+3x\\right]_{0}^{1} - \\left[\\frac{x^3}{3}-2x^2+3x\\right]_{1}^{3} + \\left[\\frac{x^3}{3}-2x^2+3x\\right]_{3}^{4} \\end{array} \\right.', - }, - }, - ], - }, + paragraph( + 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(' 가 된다.') + ), ], }, }, @@ -3489,43 +3388,25 @@ export const mockHomeCards: HomeCard[] = [ { badges: [{ text: '집중학습', variant: 'green' }], title: { - type: 'doc', - content: [paragraph(text('어드민 헤드라인/권장 15글자/선택'))], - }, - description: { - type: 'doc', - content: [ - paragraph( - text( - '어드민에 등록된 Title 영역입니다. 권장길이 공백 포함 26~38자, 최대 공백포함 60자.' - ) - ), - ], - }, - content: { type: 'doc', content: [ - paragraph( - text( - 'asdfasdfasdfsadfasdfasdfaasdfasdfasdfasdfsadfsdfsdfsdfsdfsdfdsfdsfsdfsdfsdfsdfdssdfsdfsdfasdfasdfasdfsadfasdfasdfaasdfasdfasdfasdfsadfsdfsdfsdfsdfsdfdsfdsfsdfsdfsdfsdfdssdfsdfsdfasdfasdfasdfsadfasdfasdfaasdfasdfasdfasdfsadfsdfsdfsdfsdfsdfdsfdsfsdfsdfsdfsdfdssdfsdfsdf' - ) - ), + { + type: 'paragraph', + attrs: { textAlign: null }, + content: [ + { type: 'inlineMath', attrs: { latex: '\\int x^n \\,dx' } }, + { text: '의 일반형', type: 'text' }, + ], + }, ], }, - }, - { - badges: [{ text: '집중학습', variant: 'green' }], - title: { - type: 'doc', - content: [paragraph(text('어드민 헤드라인/권장 15글자/선택'))], - }, description: { type: 'doc', content: [ paragraph( - text( - '어드민에 등록된 Title 영역입니다. 권장길이 공백 포함 26~38자, 최대 공백포함 60자.' - ) + text('지수가 '), + inlineMath('n \\neq -1'), + text('일 때 부정적분의 일반형을 익혀봐요.') ), ], }, @@ -3533,9 +3414,16 @@ export const mockHomeCards: HomeCard[] = [ type: 'doc', content: [ paragraph( - text( - 'asdfasdfasdfsadfasdfasdfaasdfasdfasdfasdfsadfsdfsdfsdfsdfsdfdsfdsfsdfsdfsdfsdfdssdfsdfsdfasdfasdfasdfsadfasdfasdfaasdfasdfasdfasdfsadfsdfsdfsdfsdfsdfdsfdsfsdfsdfsdfsdfdssdfsdfsdfasdfasdfasdfsadfasdfasdfaasdfasdfasdfasdfsadfsdfsdfsdfsdfsdfdsfdsfsdfsdfsdfsdfdssdfsdfsdf' - ) + 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(' 가 된다.') ), ], }, @@ -3545,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 d5c76a44..b313219d 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[]; } diff --git a/packages/pointer-content-renderer/src/web/main.ts b/packages/pointer-content-renderer/src/web/main.ts index 9b94dafa..d5cb01cc 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) { From 561f217e7767f8c7991ec71e3c7d6db851efc059 Mon Sep 17 00:00:00 2001 From: sterdsterd Date: Wed, 13 May 2026 01:19:06 +0900 Subject: [PATCH 6/7] feat(native): combine today and tomorrow focus cards via useHomeFocusCards hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useQueries 병렬 fetch로 오늘+내일 집중학습 발급분을 합산. useFocusEffect + queryClient.invalidateQueries 로 화면 진입 시 데이터 최신화. buildHomeInit에 todayStr 파라미터 추가해 todayStr single source-of-truth 유지. --- .../student/home/hooks/useHomeFocusCards.ts | 41 ++++++++++ .../student/home/screens/HomeScreen.tsx | 75 ++++++------------- .../home/transforms/homeContentTransforms.ts | 51 ++++++------- 3 files changed, 85 insertions(+), 82 deletions(-) create mode 100644 apps/native/src/features/student/home/hooks/useHomeFocusCards.ts 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 00000000..2406d8ef --- /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 0138022a..6c15bd02 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,39 +32,29 @@ const HomeScreen = () => { }); // ── 홈 카드 API ── - const today = new Date(); - const tomorrow = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 1); - const todayStr = formatDateKey(today); - const tomorrowStr = formatDateKey(tomorrow); + // 마운트 시점의 오늘 — 디바이스 자정 넘김 시 자동 갱신은 별도 처리(useFocusEffect)에 위임. + const today = useMemo(() => new Date(), []); + const todayStr = useMemo(() => formatDateKey(today), [today]); const { data: dailyComments } = useGetDailyComments(todayStr); - const { data: todayFocusCards } = useGetFocusCards(todayStr); - const { data: tomorrowFocusCards } = useGetFocusCards(tomorrowStr); - - const focusCards = useMemo(() => { - if (!todayFocusCards && !tomorrowFocusCards) return null; - const todayData = todayFocusCards?.data ?? []; - const tomorrowData = tomorrowFocusCards?.data ?? []; - const data = [...todayData, ...tomorrowData]; - return { - requestId: todayFocusCards?.requestId ?? tomorrowFocusCards?.requestId ?? '', - total: data.length, - data, - }; - }, [todayFocusCards, tomorrowFocusCards]); + 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; @@ -86,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 03795867..1b6ed2e4 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[] = []; @@ -77,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; @@ -113,7 +105,6 @@ export function buildHomeInit(opts: { return { type: 'init', mode: 'home', - name: opts.name, cards, }; } From 3cd383d28c2a51ca9886c8358d5eb41b94e43667 Mon Sep 17 00:00:00 2001 From: sterdsterd Date: Wed, 13 May 2026 01:19:16 +0900 Subject: [PATCH 7/7] refactor(content-renderer): unify home and chat collapsible animations with iOS-style easing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --motion-ease / --motion-duration CSS 변수 추출 → home + chat 모드에 일관 적용. study-item collapsible 을 outer(height transition) + inner(padding 고정) 구조로 재정비, transitionend 후 height: auto 해제로 동적 콘텐츠 높이 자동 대응. 빈 title/description doc 시 element 자체 생략, CSS 클래스를 스키마 변수명에 정렬. 시간 뱃지를 1분 간격 자동 갱신(setupTimeBadgeTick)으로 전환. --- .../src/web/core/styles/base.css | 4 + .../src/web/modes/chat/chat.css | 2 +- .../src/web/modes/home/home-renderer.ts | 164 +++++++++++------- .../src/web/modes/home/home.css | 29 ++-- 4 files changed, 118 insertions(+), 81 deletions(-) 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 dc7b1f6b..c5b929f5 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/modes/chat/chat.css b/packages/pointer-content-renderer/src/web/modes/chat/chat.css index c615fd84..41550667 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 8fdfccba..44ab38c0 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 () => { + timeDisposer(); + collapseDisposer(); + }; +} - return disposer; +/** + * 만료 시각으로부터 남은 시간(시간 단위, 올림)을 계산해 뱃지를 갱신. + * 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,63 +312,78 @@ async function renderStudyItem( headline.appendChild(b); } - const title = el('div', 'home-study-item-headline-text'); - title.innerHTML = serializeJSONToHTML(item.title); - headline.appendChild(title); + // 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 description = el('div', 'home-study-item-title'); - description.innerHTML = serializeJSONToHTML(item.description); + headerSection.appendChild(headline); - headerSection.append(headline, description); + // 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(description); - 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 f35785f1..ad1bcab4 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 { @@ -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); }