From 37a0c9f99ddd8906169ae7e870d60ef16a08bfcf Mon Sep 17 00:00:00 2001 From: 10tentacion Date: Tue, 21 Jul 2026 22:30:55 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20ky=20+=20TanStack=20Query=20API=20?= =?UTF-8?q?=ED=81=B4=EB=9D=BC=EC=9D=B4=EC=96=B8=ED=8A=B8=20=EC=84=B8?= =?UTF-8?q?=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /api 명세(GET news/graph/verify, GET news/{id}/analysis, POST briefing)에 맞춘 ky 인스턴스와 쿼리 훅을 추가한다. placeholderData는 lib/api.ts의 기존 동기 함수를 재사용해 실 백엔드가 없어도 즉시 데이터가 보이게 한다. 아직 어떤 컴포넌트도 이 훅을 쓰지 않으므로 기존 동작에는 영향이 없다. Co-Authored-By: Claude Sonnet 5 --- .../tanstack-query/root-provider.tsx | 10 +- src/lib/http.ts | 11 +++ src/lib/queries.ts | 92 +++++++++++++++++++ 3 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 src/lib/http.ts create mode 100644 src/lib/queries.ts diff --git a/src/integrations/tanstack-query/root-provider.tsx b/src/integrations/tanstack-query/root-provider.tsx index a4ff9d7..395b890 100644 --- a/src/integrations/tanstack-query/root-provider.tsx +++ b/src/integrations/tanstack-query/root-provider.tsx @@ -1,7 +1,15 @@ import { QueryClient } from '@tanstack/react-query' export function getContext() { - const queryClient = new QueryClient() + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30_000, + retry: 1, + refetchOnWindowFocus: false, + }, + }, + }) return { queryClient, diff --git a/src/lib/http.ts b/src/lib/http.ts new file mode 100644 index 0000000..6195def --- /dev/null +++ b/src/lib/http.ts @@ -0,0 +1,11 @@ +import ky from 'ky' + +/** + * 모든 API 호출의 공통 진입점. prefix는 VITE_API_BASE_URL이 있으면 그쪽으로, + * 없으면 같은 오리진의 /api로 붙는다 (개발 중에는 MSW가 이 경로를 가로챈다). + */ +export const http = ky.create({ + prefix: import.meta.env.VITE_API_BASE_URL ?? '/api', + timeout: 10_000, + retry: { limit: 1 }, +}) diff --git a/src/lib/queries.ts b/src/lib/queries.ts new file mode 100644 index 0000000..ef4750b --- /dev/null +++ b/src/lib/queries.ts @@ -0,0 +1,92 @@ +import { useMutation, useQuery } from '@tanstack/react-query' +import { http } from '#/lib/http' +import { getGraph, getNews, getNewsAnalysis, getVerify } from '#/lib/api' +import type { + BriefingResult, + NewsAnalysis, + NewsListItem, + OntologyEdge, + OntologyNode, + VerifyResponse, +} from '#/types' + +/** + * docs/KOSLINK-FRONTEND.md §5 API 명세에 맞춘 fetch 계층 + TanStack Query 훅. + * 각 훅의 placeholderData는 lib/api.ts의 동기 함수(§11 데모 안전장치)를 그대로 + * 재사용한다 — MSW 핸들러도 같은 함수를 응답 생성에 쓰므로 두 경로가 항상 + * 같은 데이터를 본다. + */ + +export const queryKeys = { + news: (sector: string) => ['news', sector] as const, + newsAnalysis: (newsId: string) => ['news', newsId, 'analysis'] as const, + graph: () => ['graph'] as const, + verify: () => ['verify'] as const, +} + +async function fetchNews(sector?: string): Promise { + const searchParams = + sector && sector !== '전체' ? { sector } : undefined + const { items } = await http + .get('news', { searchParams }) + .json<{ items: NewsListItem[] }>() + return items +} + +async function fetchNewsAnalysis(newsId: string): Promise { + return http.get(`news/${newsId}/analysis`).json() +} + +async function fetchGraph(): Promise<{ + nodes: OntologyNode[] + edges: OntologyEdge[] +}> { + return http.get('graph').json() +} + +async function fetchVerify(): Promise { + return http.get('verify').json() +} + +async function postBriefing(tickers: string[]): Promise { + return http.post('briefing', { json: { tickers } }).json() +} + +export function useNewsQuery(sector: string) { + return useQuery({ + queryKey: queryKeys.news(sector), + queryFn: () => fetchNews(sector), + placeholderData: () => getNews(sector), + }) +} + +export function useNewsAnalysisQuery(newsId: string | null) { + return useQuery({ + queryKey: queryKeys.newsAnalysis(newsId ?? ''), + queryFn: () => fetchNewsAnalysis(newsId as string), + enabled: !!newsId, + placeholderData: () => + newsId ? (getNewsAnalysis(newsId) ?? undefined) : undefined, + }) +} + +export function useGraphQuery() { + return useQuery({ + queryKey: queryKeys.graph(), + queryFn: fetchGraph, + placeholderData: getGraph, + staleTime: Infinity, + }) +} + +export function useVerifyQuery() { + return useQuery({ + queryKey: queryKeys.verify(), + queryFn: fetchVerify, + placeholderData: getVerify, + }) +} + +export function useBriefingMutation() { + return useMutation({ mutationFn: postBriefing }) +} From 689ab7045a135c6864ab6d9368664ce0b49cbe79 Mon Sep 17 00:00:00 2001 From: 10tentacion Date: Tue, 21 Jul 2026 22:36:32 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20MSW=EB=A1=9C=20/api=20=EB=AA=A9=20?= =?UTF-8?q?=ED=95=B8=EB=93=A4=EB=9F=AC=20=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lib/api.ts의 동기 함수를 그대로 응답 생성에 재사용하는 MSW 핸들러로 GET/POST /api/* 를 가로챈다. 개발 모드에서는 커스텀 client.tsx 엔트리가 하이드레이션 전에 워커 시작을 기다려 첫 쿼리부터 목 응답을 받는다. 프로덕션 빌드에서는 import.meta.env.DEV 분기로 관련 코드가 빠진다. Co-Authored-By: Claude Sonnet 5 --- package.json | 6 + pnpm-lock.yaml | 269 ++++++++++++++++++++++++++- public/mockServiceWorker.js | 361 ++++++++++++++++++++++++++++++++++++ src/client.tsx | 27 +++ src/mocks/browser.ts | 4 + src/mocks/handlers.ts | 46 +++++ 6 files changed, 709 insertions(+), 4 deletions(-) create mode 100644 public/mockServiceWorker.js create mode 100644 src/client.tsx create mode 100644 src/mocks/browser.ts create mode 100644 src/mocks/handlers.ts diff --git a/package.json b/package.json index 5b80906..8e75ae2 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "@vitejs/plugin-react": "^6.0.1", "eslint": "^9.20.0", "jsdom": "^28.1.0", + "msw": "^2.15.0", "prettier": "^3.8.1", "typescript": "^6.0.2", "vite": "^8.0.0", @@ -62,5 +63,10 @@ "esbuild", "lightningcss" ] + }, + "msw": { + "workerDirectory": [ + "public" + ] } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2984144..12204f2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -111,6 +111,9 @@ importers: jsdom: specifier: ^28.1.0 version: 28.1.0 + msw: + specifier: ^2.15.0 + version: 2.15.0(@types/node@22.20.1)(typescript@6.0.3) prettier: specifier: ^3.8.1 version: 3.9.5 @@ -122,7 +125,7 @@ importers: version: 8.1.5(@types/node@22.20.1)(jiti@2.7.0) vitest: specifier: ^4.1.5 - version: 4.1.10(@types/node@22.20.1)(jsdom@28.1.0)(vite@8.1.5(@types/node@22.20.1)(jiti@2.7.0)) + version: 4.1.10(@types/node@22.20.1)(jsdom@28.1.0)(msw@2.15.0(@types/node@22.20.1)(typescript@6.0.3))(vite@8.1.5(@types/node@22.20.1)(jiti@2.7.0)) packages: @@ -367,6 +370,41 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} + '@inquirer/ansi@2.0.7': + resolution: {integrity: sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + + '@inquirer/confirm@6.1.1': + resolution: {integrity: sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/core@11.2.1': + resolution: {integrity: sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/figures@2.0.7': + resolution: {integrity: sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + + '@inquirer/type@4.0.7': + resolution: {integrity: sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -383,6 +421,10 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@mswjs/interceptors@0.41.9': + resolution: {integrity: sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w==} + engines: {node: '>=18'} + '@napi-rs/wasm-runtime@1.1.6': resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} peerDependencies: @@ -405,6 +447,18 @@ packages: resolution: {integrity: sha512-hAX0pT/73190NLqBPPWSdBVGtbY6VOhWYK3qqHqtXQ1gK7kS2yz4+ivsN07hpJ6I3aeMtKP6J6npsEKOAzuTLA==} engines: {node: '>=20.0'} + '@open-draft/deferred-promise@2.2.0': + resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} + + '@open-draft/deferred-promise@3.0.0': + resolution: {integrity: sha512-XW375UK8/9SqUVNVa6M0yEy8+iTi4QN5VZ7aZuRFQmy76LRwI9wy5F4YIBU6T+eTe2/DNDo8tqu8RHlwLHM6RA==} + + '@open-draft/logger@0.3.0': + resolution: {integrity: sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==} + + '@open-draft/until@2.1.0': + resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} + '@oxc-parser/binding-android-arm-eabi@0.120.0': resolution: {integrity: sha512-WU3qtINx802wOl8RxAF1v0VvmC2O4D9M8Sv486nLeQ7iPHVmncYZrtBhB4SYyX+XZxj2PNnCcN+PW21jHgiOxg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1757,6 +1811,12 @@ packages: '@types/react@19.2.17': resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} + '@types/set-cookie-parser@2.4.10': + resolution: {integrity: sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw==} + + '@types/statuses@2.0.6': + resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} + '@typescript-eslint/eslint-plugin@8.64.0': resolution: {integrity: sha512-CGvQPBxN3wZLu6Rz2kFUpZeoCm78xUic92ck39KPePkO1NPOwjCqdQnm5Q87tpWw9vcBvW8XLrDXjH9PWYtJ3Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2090,6 +2150,10 @@ packages: classcat@5.0.5: resolution: {integrity: sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==} + cli-width@4.1.0: + resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} + engines: {node: '>= 12'} + cliui@8.0.1: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} @@ -2118,6 +2182,10 @@ packages: cookie-es@3.1.1: resolution: {integrity: sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==} + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -2354,6 +2422,15 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-string-truncated-width@3.0.3: + resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} + + fast-string-width@3.0.2: + resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} + + fast-wrap-ansi@0.2.2: + resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -2428,6 +2505,10 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + graphql@16.14.2: + resolution: {integrity: sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA==} + engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} + h3@2.0.1-rc.20: resolution: {integrity: sha512-28ljodXuUp0fZovdiSRq4G9OgrxCztrJe5VdYzXAB7ueRvI7pIUqLU14Xi3XqdYJ/khXjfpUOOD2EQa6CmBgsg==} engines: {node: '>=20.11.1'} @@ -2442,6 +2523,9 @@ packages: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} + headers-polyfill@5.0.1: + resolution: {integrity: sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA==} + html-encoding-sniffer@6.0.0: resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -2482,6 +2566,9 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} + is-node-process@1.2.0: + resolution: {integrity: sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==} + is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} @@ -2742,6 +2829,20 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + msw@2.15.0: + resolution: {integrity: sha512-2wQAmKkQKxRuXvYJxVhPGG0wZNBQyD06oJvxqw90XqLvptdqxdlHrFUfEteKkpaNORX3Xzc+HtEl/q0nfmN2wQ==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + typescript: '>= 4.8.x' + peerDependenciesMeta: + typescript: + optional: true + + mute-stream@3.0.0: + resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==} + engines: {node: ^20.17.0 || >=22.9.0} + nanoid@3.3.16: resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -2767,6 +2868,9 @@ packages: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} + outvariant@1.4.3: + resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} + oxc-parser@0.120.0: resolution: {integrity: sha512-WyPWZlcIm+Fkte63FGfgFB8mAAk33aH9h5N9lphXVOHSXEBFFsmYdOBedVKly363aWABjZdaj/m9lBfEY4wt+w==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2794,6 +2898,9 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-to-regexp@6.3.0: + resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -2906,6 +3013,9 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + rettime@0.11.11: + resolution: {integrity: sha512-ILJRqVWBCTlg9r42fFgwVZx1gnFAcQF8mRoMkbgQfIrjEDf9nbBFDFx00oloOa+Q869FUtaYDXZvEfnecQSCoQ==} + rolldown@1.1.5: resolution: {integrity: sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2940,6 +3050,9 @@ packages: resolution: {integrity: sha512-rVQVWjjSvlINzaQPZH5JFqsqEsIWdTxY3iJZCnTL/5gQbXIRooVZKI60tVCkOVfzcRPejboxO2t0P89dg5mQaA==} engines: {node: '>=10'} + set-cookie-parser@3.1.2: + resolution: {integrity: sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -2955,6 +3068,10 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + solid-js@1.9.14: resolution: {integrity: sha512-sAEXC0Kk0S1EDg+8ysEWJDbYhA3RRoEjwuySUGlKIemeo0I5YZfOyumNjNs9Sv3y2nmhD+0rW66ag2HsMuQiGQ==} @@ -2978,9 +3095,16 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + std-env@4.2.0: resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + strict-event-emitter@0.5.1: + resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -3000,6 +3124,10 @@ packages: symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tagged-tag@1.0.0: + resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} + engines: {node: '>=20'} + tailwind-merge@3.6.0: resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==} @@ -3061,6 +3189,10 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} + type-fest@5.8.0: + resolution: {integrity: sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==} + engines: {node: '>=20'} + typescript-eslint@8.64.0: resolution: {integrity: sha512-0qg+pDNMnqYzqH9AnNK+39tejHvsShUOUUoRUgtnTGE7QuMZhiFDnozq8nHJVq+Wae6NMLKNWLg5WmkcC/ndyQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -3119,6 +3251,9 @@ packages: unrs-resolver@1.12.2: resolution: {integrity: sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==} + until-async@3.0.2: + resolution: {integrity: sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==} + update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true @@ -3628,6 +3763,33 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} + '@inquirer/ansi@2.0.7': {} + + '@inquirer/confirm@6.1.1(@types/node@22.20.1)': + dependencies: + '@inquirer/core': 11.2.1(@types/node@22.20.1) + '@inquirer/type': 4.0.7(@types/node@22.20.1) + optionalDependencies: + '@types/node': 22.20.1 + + '@inquirer/core@11.2.1(@types/node@22.20.1)': + dependencies: + '@inquirer/ansi': 2.0.7 + '@inquirer/figures': 2.0.7 + '@inquirer/type': 4.0.7(@types/node@22.20.1) + cli-width: 4.1.0 + fast-wrap-ansi: 0.2.2 + mute-stream: 3.0.0 + signal-exit: 4.1.0 + optionalDependencies: + '@types/node': 22.20.1 + + '@inquirer/figures@2.0.7': {} + + '@inquirer/type@4.0.7(@types/node@22.20.1)': + optionalDependencies: + '@types/node': 22.20.1 + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -3647,6 +3809,15 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@mswjs/interceptors@0.41.9': + dependencies: + '@open-draft/deferred-promise': 2.2.0 + '@open-draft/logger': 0.3.0 + '@open-draft/until': 2.1.0 + is-node-process: 1.2.0 + outvariant: 1.4.3 + strict-event-emitter: 0.5.1 + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@emnapi/core': 1.10.0 @@ -3678,6 +3849,17 @@ snapshots: '@oozcitak/util@10.0.0': {} + '@open-draft/deferred-promise@2.2.0': {} + + '@open-draft/deferred-promise@3.0.0': {} + + '@open-draft/logger@0.3.0': + dependencies: + is-node-process: 1.2.0 + outvariant: 1.4.3 + + '@open-draft/until@2.1.0': {} + '@oxc-parser/binding-android-arm-eabi@0.120.0': optional: true @@ -5095,6 +5277,12 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/set-cookie-parser@2.4.10': + dependencies: + '@types/node': 22.20.1 + + '@types/statuses@2.0.6': {} + '@typescript-eslint/eslint-plugin@8.64.0(@typescript-eslint/parser@8.64.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -5270,12 +5458,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.10(vite@8.1.5(@types/node@22.20.1)(jiti@2.7.0))': + '@vitest/mocker@4.1.10(msw@2.15.0(@types/node@22.20.1)(typescript@6.0.3))(vite@8.1.5(@types/node@22.20.1)(jiti@2.7.0))': dependencies: '@vitest/spy': 4.1.10 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: + msw: 2.15.0(@types/node@22.20.1)(typescript@6.0.3) vite: 8.1.5(@types/node@22.20.1)(jiti@2.7.0) '@vitest/pretty-format@4.1.10': @@ -5423,6 +5612,8 @@ snapshots: classcat@5.0.5: {} + cli-width@4.1.0: {} + cliui@8.0.1: dependencies: string-width: 4.2.3 @@ -5445,6 +5636,8 @@ snapshots: cookie-es@3.1.1: {} + cookie@1.1.1: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -5695,6 +5888,16 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-string-truncated-width@3.0.3: {} + + fast-string-width@3.0.2: + dependencies: + fast-string-truncated-width: 3.0.3 + + fast-wrap-ansi@0.2.2: + dependencies: + fast-string-width: 3.0.2 + fdir@6.5.0(picomatch@4.0.5): optionalDependencies: picomatch: 4.0.5 @@ -5748,6 +5951,8 @@ snapshots: graceful-fs@4.2.11: {} + graphql@16.14.2: {} + h3@2.0.1-rc.20: dependencies: rou3: 0.8.1 @@ -5755,6 +5960,11 @@ snapshots: has-flag@4.0.0: {} + headers-polyfill@5.0.1: + dependencies: + '@types/set-cookie-parser': 2.4.10 + set-cookie-parser: 3.1.2 + html-encoding-sniffer@6.0.0: dependencies: '@exodus/bytes': 1.15.1 @@ -5794,6 +6004,8 @@ snapshots: dependencies: is-extglob: 2.1.1 + is-node-process@1.2.0: {} + is-potential-custom-element-name@1.0.1: {} isbot@5.2.1: {} @@ -6000,6 +6212,33 @@ snapshots: ms@2.1.3: {} + msw@2.15.0(@types/node@22.20.1)(typescript@6.0.3): + dependencies: + '@inquirer/confirm': 6.1.1(@types/node@22.20.1) + '@mswjs/interceptors': 0.41.9 + '@open-draft/deferred-promise': 3.0.0 + '@types/statuses': 2.0.6 + cookie: 1.1.1 + graphql: 16.14.2 + headers-polyfill: 5.0.1 + is-node-process: 1.2.0 + outvariant: 1.4.3 + path-to-regexp: 6.3.0 + picocolors: 1.1.1 + rettime: 0.11.11 + statuses: 2.0.2 + strict-event-emitter: 0.5.1 + tough-cookie: 6.0.2 + type-fest: 5.8.0 + until-async: 3.0.2 + yargs: 17.7.3 + optionalDependencies: + typescript: 6.0.3 + transitivePeerDependencies: + - '@types/node' + + mute-stream@3.0.0: {} + nanoid@3.3.16: {} napi-postinstall@0.3.4: {} @@ -6019,6 +6258,8 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 + outvariant@1.4.3: {} + oxc-parser@0.120.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1): dependencies: '@oxc-project/types': 0.120.0 @@ -6067,6 +6308,8 @@ snapshots: path-key@3.1.1: {} + path-to-regexp@6.3.0: {} + pathe@2.0.3: {} picocolors@1.1.1: {} @@ -6207,6 +6450,8 @@ snapshots: resolve-pkg-maps@1.0.0: {} + rettime@0.11.11: {} + rolldown@1.1.5: dependencies: '@oxc-project/types': 0.139.0 @@ -6246,6 +6491,8 @@ snapshots: seroval@1.5.6: {} + set-cookie-parser@3.1.2: {} + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -6256,6 +6503,8 @@ snapshots: siginfo@2.0.0: {} + signal-exit@4.1.0: {} + solid-js@1.9.14: dependencies: csstype: 3.2.3 @@ -6272,8 +6521,12 @@ snapshots: stackback@0.0.2: {} + statuses@2.0.2: {} + std-env@4.2.0: {} + strict-event-emitter@0.5.1: {} + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -6292,6 +6545,8 @@ snapshots: symbol-tree@3.2.4: {} + tagged-tag@1.0.0: {} + tailwind-merge@3.6.0: {} tailwindcss@4.3.3: {} @@ -6340,6 +6595,10 @@ snapshots: dependencies: prelude-ls: 1.2.1 + type-fest@5.8.0: + dependencies: + tagged-tag: 1.0.0 + typescript-eslint@8.64.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3): dependencies: '@typescript-eslint/eslint-plugin': 8.64.0(@typescript-eslint/parser@8.64.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) @@ -6395,6 +6654,8 @@ snapshots: '@unrs/resolver-binding-win32-ia32-msvc': 1.12.2 '@unrs/resolver-binding-win32-x64-msvc': 1.12.2 + until-async@3.0.2: {} + update-browserslist-db@1.2.3(browserslist@4.28.6): dependencies: browserslist: 4.28.6 @@ -6442,10 +6703,10 @@ snapshots: optionalDependencies: vite: 8.1.5(@types/node@22.20.1)(jiti@2.7.0) - vitest@4.1.10(@types/node@22.20.1)(jsdom@28.1.0)(vite@8.1.5(@types/node@22.20.1)(jiti@2.7.0)): + vitest@4.1.10(@types/node@22.20.1)(jsdom@28.1.0)(msw@2.15.0(@types/node@22.20.1)(typescript@6.0.3))(vite@8.1.5(@types/node@22.20.1)(jiti@2.7.0)): dependencies: '@vitest/expect': 4.1.10 - '@vitest/mocker': 4.1.10(vite@8.1.5(@types/node@22.20.1)(jiti@2.7.0)) + '@vitest/mocker': 4.1.10(msw@2.15.0(@types/node@22.20.1)(typescript@6.0.3))(vite@8.1.5(@types/node@22.20.1)(jiti@2.7.0)) '@vitest/pretty-format': 4.1.10 '@vitest/runner': 4.1.10 '@vitest/snapshot': 4.1.10 diff --git a/public/mockServiceWorker.js b/public/mockServiceWorker.js new file mode 100644 index 0000000..0c970ef --- /dev/null +++ b/public/mockServiceWorker.js @@ -0,0 +1,361 @@ +/* eslint-disable */ +/* tslint:disable */ + +/** + * Mock Service Worker. + * @see https://github.com/mswjs/msw + * - Please do NOT modify this file. + */ + +const PACKAGE_VERSION = '2.15.0' +const INTEGRITY_CHECKSUM = '03cb67ac84128e63d7cd722a6e5b7f1e' +const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') +const activeClientIds = new Set() + +addEventListener('install', function () { + self.skipWaiting() +}) + +addEventListener('activate', function (event) { + event.waitUntil(self.clients.claim()) +}) + +addEventListener('message', async function (event) { + const clientId = Reflect.get(event.source || {}, 'id') + + if (!clientId || !self.clients) { + return + } + + const client = await self.clients.get(clientId) + + if (!client) { + return + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + switch (event.data) { + case 'KEEPALIVE_REQUEST': { + sendToClient(client, { + type: 'KEEPALIVE_RESPONSE', + }) + break + } + + case 'INTEGRITY_CHECK_REQUEST': { + sendToClient(client, { + type: 'INTEGRITY_CHECK_RESPONSE', + payload: { + packageVersion: PACKAGE_VERSION, + checksum: INTEGRITY_CHECKSUM, + }, + }) + break + } + + case 'MOCK_ACTIVATE': { + activeClientIds.add(clientId) + + sendToClient(client, { + type: 'MOCKING_ENABLED', + payload: { + client: { + id: client.id, + frameType: client.frameType, + }, + }, + }) + break + } + + case 'CLIENT_CLOSED': { + activeClientIds.delete(clientId) + + const remainingClients = allClients.filter((client) => { + return client.id !== clientId + }) + + // Unregister itself when there are no more clients + if (remainingClients.length === 0) { + self.registration.unregister() + } + + break + } + } +}) + +addEventListener('fetch', function (event) { + const requestInterceptedAt = Date.now() + + // Bypass navigation requests. + if (event.request.mode === 'navigate') { + return + } + + // Opening the DevTools triggers the "only-if-cached" request + // that cannot be handled by the worker. Bypass such requests. + if ( + event.request.cache === 'only-if-cached' && + event.request.mode !== 'same-origin' + ) { + return + } + + // Bypass all requests when there are no active clients. + // Prevents the self-unregistered worked from handling requests + // after it's been terminated (still remains active until the next reload). + if (activeClientIds.size === 0) { + return + } + + const requestId = crypto.randomUUID() + event.respondWith(handleRequest(event, requestId, requestInterceptedAt)) +}) + +/** + * @param {FetchEvent} event + * @param {string} requestId + * @param {number} requestInterceptedAt + */ +async function handleRequest(event, requestId, requestInterceptedAt) { + const client = await resolveMainClient(event) + const requestCloneForEvents = event.request.clone() + const response = await getResponse( + event, + client, + requestId, + requestInterceptedAt, + ) + + // Send back the response clone for the "response:*" life-cycle events. + // Ensure MSW is active and ready to handle the message, otherwise + // this message will pend indefinitely. + if (client && activeClientIds.has(client.id)) { + const serializedRequest = await serializeRequest(requestCloneForEvents) + + // Omit the body of server-sent event stream responses. + // Cloning such responses would prevent client-side stream cancelations + // from reaching the original stream (a teed stream only cancels its + // source once both of its branches cancel) and would buffer the + // entire stream into the unconsumed clone indefinitely. + const isEventStreamResponse = response.headers + .get('content-type') + ?.toLowerCase() + .startsWith('text/event-stream') + + // Clone the response so both the client and the library could consume it. + const responseClone = isEventStreamResponse ? null : response.clone() + + sendToClient( + client, + { + type: 'RESPONSE', + payload: { + isMockedResponse: IS_MOCKED_RESPONSE in response, + request: { + id: requestId, + ...serializedRequest, + }, + response: { + type: response.type, + status: response.status, + statusText: response.statusText, + headers: Object.fromEntries(response.headers.entries()), + body: responseClone ? responseClone.body : null, + }, + }, + }, + responseClone && responseClone.body + ? [serializedRequest.body, responseClone.body] + : [], + ) + } + + return response +} + +/** + * Resolve the main client for the given event. + * Client that issues a request doesn't necessarily equal the client + * that registered the worker. It's with the latter the worker should + * communicate with during the response resolving phase. + * @param {FetchEvent} event + * @returns {Promise} + */ +async function resolveMainClient(event) { + const client = await self.clients.get(event.clientId) + + if (activeClientIds.has(event.clientId)) { + return client + } + + if (client?.frameType === 'top-level') { + return client + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + return allClients + .filter((client) => { + // Get only those clients that are currently visible. + return client.visibilityState === 'visible' + }) + .find((client) => { + // Find the client ID that's recorded in the + // set of clients that have registered the worker. + return activeClientIds.has(client.id) + }) +} + +/** + * @param {FetchEvent} event + * @param {Client | undefined} client + * @param {string} requestId + * @param {number} requestInterceptedAt + * @returns {Promise} + */ +async function getResponse(event, client, requestId, requestInterceptedAt) { + // Clone the request because it might've been already used + // (i.e. its body has been read and sent to the client). + const requestClone = event.request.clone() + + function passthrough() { + // Cast the request headers to a new Headers instance + // so the headers can be manipulated with. + const headers = new Headers(requestClone.headers) + + // Remove the "accept" header value that marked this request as passthrough. + // This prevents request alteration and also keeps it compliant with the + // user-defined CORS policies. + const acceptHeader = headers.get('accept') + if (acceptHeader) { + const values = acceptHeader.split(',').map((value) => value.trim()) + const filteredValues = values.filter( + (value) => value !== 'msw/passthrough', + ) + + if (filteredValues.length > 0) { + headers.set('accept', filteredValues.join(', ')) + } else { + headers.delete('accept') + } + } + + return fetch(requestClone, { headers }) + } + + // Bypass mocking when the client is not active. + if (!client) { + return passthrough() + } + + // Bypass initial page load requests (i.e. static assets). + // The absence of the immediate/parent client in the map of the active clients + // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet + // and is not ready to handle requests. + if (!activeClientIds.has(client.id)) { + return passthrough() + } + + // Notify the client that a request has been intercepted. + const serializedRequest = await serializeRequest(event.request) + const clientMessage = await sendToClient( + client, + { + type: 'REQUEST', + payload: { + id: requestId, + interceptedAt: requestInterceptedAt, + ...serializedRequest, + }, + }, + [serializedRequest.body], + ) + + switch (clientMessage.type) { + case 'MOCK_RESPONSE': { + return respondWithMock(clientMessage.data) + } + + case 'PASSTHROUGH': { + return passthrough() + } + } + + return passthrough() +} + +/** + * @param {Client} client + * @param {any} message + * @param {Array} transferrables + * @returns {Promise} + */ +function sendToClient(client, message, transferrables = []) { + return new Promise((resolve, reject) => { + const channel = new MessageChannel() + + channel.port1.onmessage = (event) => { + if (event.data && event.data.error) { + return reject(event.data.error) + } + + resolve(event.data) + } + + client.postMessage(message, [ + channel.port2, + ...transferrables.filter(Boolean), + ]) + }) +} + +/** + * @param {Response} response + * @returns {Response} + */ +function respondWithMock(response) { + // Setting response status code to 0 is a no-op. + // However, when responding with a "Response.error()", the produced Response + // instance will have status code set to 0. Since it's not possible to create + // a Response instance with status code 0, handle that use-case separately. + if (response.status === 0) { + return Response.error() + } + + const mockedResponse = new Response(response.body, response) + + Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, { + value: true, + enumerable: true, + }) + + return mockedResponse +} + +/** + * @param {Request} request + */ +async function serializeRequest(request) { + return { + url: request.url, + mode: request.mode, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + cache: request.cache, + credentials: request.credentials, + destination: request.destination, + integrity: request.integrity, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + body: await request.arrayBuffer(), + keepalive: request.keepalive, + } +} diff --git a/src/client.tsx b/src/client.tsx new file mode 100644 index 0000000..637e221 --- /dev/null +++ b/src/client.tsx @@ -0,0 +1,27 @@ +import { StrictMode, startTransition } from 'react' +import { hydrateRoot } from 'react-dom/client' +import { StartClient } from '@tanstack/react-start/client' + +/** + * TanStack Start의 기본 클라이언트 엔트리(node_modules/@tanstack/react-start/ + * dist/plugin/default-entry/client.tsx)를 오버라이드한다. 개발 모드에서는 + * MSW 워커가 뜬 뒤에 하이드레이션을 시작해야 첫 쿼리 요청부터 목 응답을 + * 받는다. import.meta.env.DEV는 프로덕션 빌드에서 false로 인라인되어 이 + * 분기와 동적 import가 통째로 트리쉐이킹된다. + */ +async function enableMocking() { + if (!import.meta.env.DEV) return + const { worker } = await import('#/mocks/browser') + await worker.start({ onUnhandledRequest: 'bypass' }) +} + +enableMocking().then(() => { + startTransition(() => { + hydrateRoot( + document, + + + , + ) + }) +}) diff --git a/src/mocks/browser.ts b/src/mocks/browser.ts new file mode 100644 index 0000000..4dd03f0 --- /dev/null +++ b/src/mocks/browser.ts @@ -0,0 +1,4 @@ +import { setupWorker } from 'msw/browser' +import { handlers } from './handlers' + +export const worker = setupWorker(...handlers) diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts new file mode 100644 index 0000000..40a090e --- /dev/null +++ b/src/mocks/handlers.ts @@ -0,0 +1,46 @@ +import { HttpResponse, delay, http } from 'msw' +import { + getGraph, + getNews, + getNewsAnalysis, + getVerify, + runBriefing, +} from '#/lib/api' + +/** + * docs/KOSLINK-FRONTEND.md §5 API 명세와 동일한 JSON 모양으로 응답한다. + * 응답 바디는 lib/api.ts의 기존 동기 함수를 그대로 호출해 만든다 — 이 "서버"와 + * TanStack Query의 placeholderData가 항상 같은 데이터 소스(lib/data.ts)를 본다. + */ +export const handlers = [ + http.get('/api/news', async ({ request }) => { + await delay(300) + const sector = new URL(request.url).searchParams.get('sector') ?? undefined + return HttpResponse.json({ items: getNews(sector) }) + }), + + http.get('/api/news/:id/analysis', async ({ params }) => { + await delay(350) + const analysis = getNewsAnalysis(params.id as string) + if (!analysis) { + return new HttpResponse(null, { status: 404 }) + } + return HttpResponse.json(analysis) + }), + + http.get('/api/graph', async () => { + await delay(250) + return HttpResponse.json(getGraph()) + }), + + http.post('/api/briefing', async ({ request }) => { + await delay(400) + const { tickers } = (await request.json()) as { tickers: string[] } + return HttpResponse.json(runBriefing(tickers)) + }), + + http.get('/api/verify', async () => { + await delay(300) + return HttpResponse.json(getVerify()) + }), +] From ff5d8114c553c080dee0dcb783e083237ff946d7 Mon Sep 17 00:00:00 2001 From: 10tentacion Date: Tue, 21 Jul 2026 22:50:48 +0900 Subject: [PATCH 3/6] =?UTF-8?q?refactor:=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=EB=A5=BC=20TanStack=20Query=20=ED=9B=85=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NewsList/MobileNewsBar/AnalysisPanel/VerifyView/BriefingSheet/Header/ GraphPanel의 lib/api.ts·lib/data.ts 직접 호출을 lib/queries.ts 훅으로 교체한다. GraphPanel은 buildFocusScene/buildAllScene이 훅에서 받은 analysis/graph 데이터를 인자로 받도록 시그니처를 바꿨고, 결정론적 좌표를 계산하는 lib/layout.ts는 기존 그대로 lib/data.ts를 소스로 유지한다(MSW도 같은 소스를 쓰므로 지금은 데이터 불일치가 없다). public/mockServiceWorker.js가 eslint 대상에서 빠지도록 ignore 추가. Chromium으로 실제 흐름을 구동해 확인: 뉴스 선택→그래프 애니메이션, 전체 관계망 전환, 관심종목 브리핑 뮤테이션, 예측 검증 화면이 모두 정상 동작하고 5개 /api 요청이 전부 MSW(fromServiceWorker)로 200 응답, 콘솔 에러 없음. 프로덕션 빌드 결과물에는 MSW 코드가 포함되지 않음을 확인. Co-Authored-By: Claude Sonnet 5 --- eslint.config.js | 6 ++- src/components/Header.tsx | 6 ++- src/components/analysis/AnalysisPanel.tsx | 7 ++-- src/components/briefing/BriefingSheet.tsx | 26 ++++++++----- src/components/graph/GraphPanel.tsx | 45 ++++++++++++++--------- src/components/news/MobileNewsBar.tsx | 4 +- src/components/news/NewsList.tsx | 4 +- src/components/verify/VerifyView.tsx | 6 ++- src/lib/queries.ts | 3 +- 9 files changed, 66 insertions(+), 41 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index d3f64be..502d7b0 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -27,6 +27,10 @@ export default [ }, }, { - ignores: ['eslint.config.js', 'prettier.config.js'], + ignores: [ + 'eslint.config.js', + 'prettier.config.js', + 'public/mockServiceWorker.js', + ], }, ] diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 37ae195..3908068 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -1,6 +1,6 @@ import { useAtom } from 'jotai' import { viewAtom } from '#/lib/atoms' -import { ONTOLOGY_EDGES, ONTOLOGY_NODES } from '#/lib/data' +import { useGraphQuery } from '#/lib/queries' const TABS = [ { value: 'map', label: '뉴스맵' }, @@ -9,6 +9,7 @@ const TABS = [ export default function Header() { const [view, setView] = useAtom(viewAtom) + const { data: graph } = useGraphQuery() return (
- 온톨로지 {ONTOLOGY_NODES.length}개 노드 · {ONTOLOGY_EDGES.length}개 관계 + 온톨로지 {graph?.nodes.length ?? 0}개 노드 · {graph?.edges.length ?? 0} + 개 관계
) diff --git a/src/components/analysis/AnalysisPanel.tsx b/src/components/analysis/AnalysisPanel.tsx index 5f8e4bf..626aec6 100644 --- a/src/components/analysis/AnalysisPanel.tsx +++ b/src/components/analysis/AnalysisPanel.tsx @@ -1,6 +1,6 @@ import { useAtom, useAtomValue } from 'jotai' import { selectedNewsIdAtom, verifyContextAtom, viewAtom } from '#/lib/atoms' -import { getNews, getNewsAnalysis } from '#/lib/api' +import { useNewsAnalysisQuery, useNewsQuery } from '#/lib/queries' import MainStockCard from './MainStockCard' import RelatedList from './RelatedList' import RationaleBox from './RationaleBox' @@ -10,10 +10,11 @@ export default function AnalysisPanel() { const [, setVerifyContext] = useAtom(verifyContextAtom) const [, setView] = useAtom(viewAtom) + const { data: newsList = [] } = useNewsQuery('전체') + const { data: analysis } = useNewsAnalysisQuery(selectedNewsId) const newsItem = selectedNewsId - ? getNews().find((n) => n.id === selectedNewsId) + ? newsList.find((n) => n.id === selectedNewsId) : undefined - const analysis = selectedNewsId ? getNewsAnalysis(selectedNewsId) : null function openVerifyForThisNews() { if (!analysis || !newsItem) return diff --git a/src/components/briefing/BriefingSheet.tsx b/src/components/briefing/BriefingSheet.tsx index a4fc174..ff2c85c 100644 --- a/src/components/briefing/BriefingSheet.tsx +++ b/src/components/briefing/BriefingSheet.tsx @@ -7,7 +7,7 @@ import { selectedNewsIdAtom, viewAtom, } from '#/lib/atoms' -import { runBriefing } from '#/lib/api' +import { useBriefingMutation } from '#/lib/queries' import { impactOf } from '#/lib/format' import { graphIndex } from '#/lib/layout' import { @@ -37,6 +37,7 @@ export default function BriefingSheet() { const setView = useSetAtom(viewAtom) const [inputValue, setInputValue] = useState('') const [open, setOpen] = useState(false) + const briefingMutation = useBriefingMutation() function addTicker(raw: string) { const value = raw.trim() @@ -52,13 +53,17 @@ export default function BriefingSheet() { function run() { if (tickers.length === 0) return - setResult(runBriefing(tickers)) - setRanAt( - new Date().toLocaleTimeString('ko-KR', { - hour: '2-digit', - minute: '2-digit', - }), - ) + briefingMutation.mutate(tickers, { + onSuccess: (data) => { + setResult(data) + setRanAt( + new Date().toLocaleTimeString('ko-KR', { + hour: '2-digit', + minute: '2-digit', + }), + ) + }, + }) } function clearAll() { @@ -148,10 +153,11 @@ export default function BriefingSheet() { {!result && tickers.length === 0 && ( diff --git a/src/components/graph/GraphPanel.tsx b/src/components/graph/GraphPanel.tsx index 3215fff..05d338f 100644 --- a/src/components/graph/GraphPanel.tsx +++ b/src/components/graph/GraphPanel.tsx @@ -11,9 +11,12 @@ import type { Node, NodeMouseHandler } from '@xyflow/react' import '@xyflow/react/dist/style.css' // 필수 — 빠뜨리면 노드가 겹쳐 보인다 import { useAtom, useAtomValue } from 'jotai' import { cn } from '#/lib/utils' -import { graphModeAtom, highlightedNodeAtom, selectedNewsIdAtom } from '#/lib/atoms' -import { getNewsAnalysis } from '#/lib/api' -import { ONTOLOGY_EDGES, ONTOLOGY_NODES } from '#/lib/data' +import { + graphModeAtom, + highlightedNodeAtom, + selectedNewsIdAtom, +} from '#/lib/atoms' +import { useGraphQuery, useNewsAnalysisQuery } from '#/lib/queries' import { sizeOf } from '#/lib/format' import { focusBuild, @@ -23,7 +26,12 @@ import { tierOf, } from '#/lib/layout' import type { LayoutPoint } from '#/lib/layout' -import type { Direction, OntologyNode } from '#/types' +import type { + Direction, + NewsAnalysis, + OntologyEdge, + OntologyNode, +} from '#/types' import StockNode from './StockNode' import RelEdge from './RelEdge' import { usePropagation } from './usePropagation' @@ -79,10 +87,7 @@ function polarityEdgeState(polarity: 1 | -1, tier: 1 | 2): EdgeVisualState { } /** 뉴스 파급 경로: 기점 중앙, related[].chain 기반 동심원 배치 */ -function buildFocusScene(newsId: string): Scene2 | null { - const analysis = getNewsAnalysis(newsId) - if (!analysis) return null - +function buildFocusScene(analysis: NewsAnalysis): Scene2 { const built = focusBuild(analysis.main.nodeId, analysis.related) const dirOf = new Map() dirOf.set(analysis.main.nodeId, analysis.main.direction) @@ -135,7 +140,7 @@ function buildFocusScene(newsId: string): Scene2 | null { nodes, edges, scene: { - key: `focus:${newsId}`, + key: `focus:${analysis.newsId}`, originId: analysis.main.nodeId, originName: graphIndex.byId.get(analysis.main.nodeId)?.name ?? '', originState: analysis.main.direction === 'UP' ? 'up' : 'down', @@ -149,9 +154,12 @@ function buildFocusScene(newsId: string): Scene2 | null { } /** 전체 관계망: 섹터 3클러스터 고정 좌표. highlightId가 있으면 배치는 그대로 두고 그 자리에서 2단계 파급만 강조. */ -function buildAllScene(highlightId: string | null): Scene2 { +function buildAllScene( + highlightId: string | null, + graph: { nodes: OntologyNode[]; edges: OntologyEdge[] }, +): Scene2 { const { pos } = FULL_LAYOUT - const ids = ONTOLOGY_NODES.map((n) => n.id) + const ids = graph.nodes.map((n) => n.id) if (!highlightId) { const nodes: NodeSpec[] = ids.map((id) => ({ @@ -161,7 +169,7 @@ function buildAllScene(highlightId: string | null): Scene2 { badge: null, tier: tierOf(id), })) - const edges: EdgeSpec[] = ONTOLOGY_EDGES.map((e) => ({ + const edges: EdgeSpec[] = graph.edges.map((e) => ({ id: e.id, source: e.source, target: e.target, @@ -186,7 +194,7 @@ function buildAllScene(highlightId: string | null): Scene2 { const origin = graphIndex.byId.get(highlightId)! function ontologyEdgeId(source: string, target: string): string { - const match = ONTOLOGY_EDGES.find( + const match = graph.edges.find( (e) => (e.source === source && e.target === target) || (e.source === target && e.target === source), @@ -201,7 +209,7 @@ function buildAllScene(highlightId: string | null): Scene2 { badge: null, tier: tierOf(id), })) - const edges: EdgeSpec[] = ONTOLOGY_EDGES.map((e) => ({ + const edges: EdgeSpec[] = graph.edges.map((e) => ({ id: e.id, source: e.source, target: e.target, @@ -601,6 +609,8 @@ export default function GraphPanel() { const [mode, setMode] = useAtom(graphModeAtom) const [highlightedNode, setHighlightedNode] = useAtom(highlightedNodeAtom) const selectedNewsId = useAtomValue(selectedNewsIdAtom) + const { data: graph } = useGraphQuery() + const { data: analysis } = useNewsAnalysisQuery(selectedNewsId) // 뉴스가 바뀌면 제자리 강조 상태를 초기화하고 파급 경로 뷰로 돌아간다 useEffect(() => { @@ -609,10 +619,11 @@ export default function GraphPanel() { }, [selectedNewsId, setHighlightedNode, setMode]) const scene2: Scene2 | null = useMemo(() => { - if (mode === 'all') return buildAllScene(highlightedNode) - if (selectedNewsId) return buildFocusScene(selectedNewsId) + if (mode === 'all') + return graph ? buildAllScene(highlightedNode, graph) : null + if (selectedNewsId && analysis) return buildFocusScene(analysis) return null - }, [mode, highlightedNode, selectedNewsId]) + }, [mode, highlightedNode, selectedNewsId, graph, analysis]) return (
diff --git a/src/components/news/MobileNewsBar.tsx b/src/components/news/MobileNewsBar.tsx index 4f14840..4494902 100644 --- a/src/components/news/MobileNewsBar.tsx +++ b/src/components/news/MobileNewsBar.tsx @@ -5,7 +5,7 @@ import { sectorFilterAtom, selectedNewsIdAtom, } from '#/lib/atoms' -import { getNews } from '#/lib/api' +import { useNewsQuery } from '#/lib/queries' /** ≤1080px에서만 보이는 상단 현재뉴스 바 + 바텀시트 배경 스크림. CSS가 그 이상 폭에서는 숨긴다. */ export default function MobileNewsBar() { @@ -13,7 +13,7 @@ export default function MobileNewsBar() { const [selectedId, setSelectedId] = useAtom(selectedNewsIdAtom) const [sheetOpen, setSheetOpen] = useAtom(newsSheetOpenAtom) - const list = getNews().filter((n) => sector === '전체' || n.sector === sector) + const { data: list = [] } = useNewsQuery(sector) const index = list.findIndex((n) => n.id === selectedId) const current = index >= 0 ? list[index] : null diff --git a/src/components/news/NewsList.tsx b/src/components/news/NewsList.tsx index 709fe31..53a8a23 100644 --- a/src/components/news/NewsList.tsx +++ b/src/components/news/NewsList.tsx @@ -4,7 +4,7 @@ import { sectorFilterAtom, selectedNewsIdAtom, } from '#/lib/atoms' -import { getNews } from '#/lib/api' +import { useNewsQuery } from '#/lib/queries' import NewsCard from './NewsCard' const SECTORS = ['전체', '반도체', '2차전지', '방산'] @@ -15,7 +15,7 @@ export default function NewsList() { const [selectedId, setSelectedId] = useAtom(selectedNewsIdAtom) const setSheetOpen = useSetAtom(newsSheetOpenAtom) - const news = getNews().filter((n) => sector === '전체' || n.sector === sector) + const { data: news = [] } = useNewsQuery(sector) return ( <> diff --git a/src/components/verify/VerifyView.tsx b/src/components/verify/VerifyView.tsx index 23c4cea..47ab459 100644 --- a/src/components/verify/VerifyView.tsx +++ b/src/components/verify/VerifyView.tsx @@ -1,7 +1,7 @@ import { useMemo, useState } from 'react' import { useAtom } from 'jotai' import { verifyContextAtom } from '#/lib/atoms' -import { getVerify } from '#/lib/api' +import { useVerifyQuery } from '#/lib/queries' import type { VerifyDaily } from '#/types' import VerifyList, { hitCount } from './VerifyList' import VerifyDetail from './VerifyDetail' @@ -95,7 +95,9 @@ export default function VerifyView() { const [sector, setSector] = useState('전체') const [selectedId, setSelectedId] = useState(null) - const { daily, news } = useMemo(() => getVerify(), []) + const { data } = useVerifyQuery() + const daily = data?.daily ?? [] + const news = data?.news ?? [] const rows = useMemo( () => diff --git a/src/lib/queries.ts b/src/lib/queries.ts index ef4750b..d73a68d 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -25,8 +25,7 @@ export const queryKeys = { } async function fetchNews(sector?: string): Promise { - const searchParams = - sector && sector !== '전체' ? { sector } : undefined + const searchParams = sector && sector !== '전체' ? { sector } : undefined const { items } = await http .get('news', { searchParams }) .json<{ items: NewsListItem[] }>() From e05d3bc17b2f8261b6678634ae95620b10d8e357 Mon Sep 17 00:00:00 2001 From: 10tentacion Date: Wed, 22 Jul 2026 00:41:31 +0900 Subject: [PATCH 4/6] =?UTF-8?q?feat:=20=EB=89=B4=EC=8A=A4=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=BB=A4=EC=84=9C=20=EA=B8=B0=EB=B0=98=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=95(=EB=AC=B4=ED=95=9C=20=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=EB=A1=A4)=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /api/news가 전체 목록을 한 번에 반환하던 것을 cursor/nextCursor 기반 페이징으로 바꾼다. useNewsQuery를 useInfiniteQuery로 전환하고, NewsList는 IntersectionObserver sentinel로 하단 도달 시 다음 페이지를 자동 요청한다. MobileNewsBar의 이전/다음 탐색도 로드된 페이지 끝에서 다음 페이지를 이어 받도록 처리. 뉴스 목록이 페이지네이션되면서 AnalysisPanel이 선택된 뉴스를 찾으려고 전체 목록을 별도 조회하던 방식은 깨지므로, NewsAnalysis 응답에 title/sector를 추가해 분석 패널이 목록 없이도 동작하도록 함께 고쳤다. lib/api.ts의 getNews는 이제 { sector?, cursor?, limit? } → { items, nextCursor } 시그니처. MSW 핸들러와 docs/KOSLINK-FRONTEND.md §5.1/5.2도 동일하게 갱신. cursor는 클라이언트가 해석하지 않는 opaque 값으로 문서화. Chromium으로 확인: limit=3으로 커서를 이어 요청하면 6건이 정확히 2페이지(n1-3 → n4-6, 두 번째 페이지 nextCursor=null)로 나뉨, analysis 응답에 title/sector 포함, 기존 화면 동작 회귀 없음. Co-Authored-By: Claude Sonnet 5 --- docs/KOSLINK-FRONTEND.md | 15 ++++++- src/components/analysis/AnalysisPanel.tsx | 12 ++---- src/components/news/MobileNewsBar.tsx | 16 ++++++-- src/components/news/NewsList.tsx | 28 ++++++++++++- src/lib/api.ts | 50 ++++++++++++++++++----- src/lib/queries.ts | 30 +++++++++----- src/mocks/handlers.ts | 7 +++- src/routes/index.tsx | 2 +- src/types/index.ts | 9 ++++ 9 files changed, 129 insertions(+), 40 deletions(-) diff --git a/docs/KOSLINK-FRONTEND.md b/docs/KOSLINK-FRONTEND.md index f21a9b0..d610ae7 100644 --- a/docs/KOSLINK-FRONTEND.md +++ b/docs/KOSLINK-FRONTEND.md @@ -126,10 +126,10 @@ interface OntologyEdge { 베이스 `/api`. 인증 헤더 없음. 전부 TanStack Query로 감싼다. -### 5.1 뉴스 목록 +### 5.1 뉴스 목록 (커서 기반 페이징 · 무한 스크롤) ``` -GET /api/news?limit=20§or=반도체 +GET /api/news?limit=20§or=반도체&cursor=n3 ``` ```jsonc @@ -143,9 +143,15 @@ GET /api/news?limit=20§or=반도체 "sector": "반도체", }, ], + "nextCursor": "n1", // 다음 페이지 요청 시 그대로 실어 보낸다. 마지막 페이지면 null } ``` +`cursor`는 클라이언트가 값을 해석하지 않는 opaque 문자열이다 — 이전 응답의 +`nextCursor`를 다음 요청의 `cursor`에 그대로 담아 보낸다. 첫 요청은 `cursor`를 +생략한다. 프론트는 `useInfiniteQuery`로 페이지를 쌓고, 목록 하단 sentinel이 +뷰포트에 들어오면 다음 페이지를 요청한다(`lib/queries.ts`의 `useNewsQuery`). + ### 5.2 뉴스 영향 분석 — 화면의 핵심 ``` @@ -155,6 +161,8 @@ GET /api/news/{id}/analysis ```jsonc { "newsId": "n1", + "title": "SK하이닉스, HBM4 양산 위해 청주 M15X 증설 확정", + "sector": "반도체", "article": { "summary": ["요약 1", "요약 2", "요약 3"], "originUrl": "https://www.yna.co.kr/...", @@ -199,6 +207,9 @@ GET /api/news/{id}/analysis - 영향의 크기는 `chain.length - 1` (hop 수)로만 표현한다 - `chain`은 기점부터 대상까지의 노드 ID 배열. 그래프 배치와 애니메이션 순서가 전부 여기서 나온다 - `rationale.propagation`은 서버가 그래프 경로에서 템플릿 생성한다 +- `title`/`sector`는 뉴스 목록(§5.1)이 페이지네이션되어 선택된 뉴스가 이미 + 로드된 페이지에 없을 수 있으므로, 분석 패널이 목록을 다시 훑지 않고도 + 헤더에 표시할 수 있게 여기 포함한다 ### 5.3 전체 그래프 diff --git a/src/components/analysis/AnalysisPanel.tsx b/src/components/analysis/AnalysisPanel.tsx index 626aec6..663df59 100644 --- a/src/components/analysis/AnalysisPanel.tsx +++ b/src/components/analysis/AnalysisPanel.tsx @@ -1,6 +1,6 @@ import { useAtom, useAtomValue } from 'jotai' import { selectedNewsIdAtom, verifyContextAtom, viewAtom } from '#/lib/atoms' -import { useNewsAnalysisQuery, useNewsQuery } from '#/lib/queries' +import { useNewsAnalysisQuery } from '#/lib/queries' import MainStockCard from './MainStockCard' import RelatedList from './RelatedList' import RationaleBox from './RationaleBox' @@ -10,16 +10,12 @@ export default function AnalysisPanel() { const [, setVerifyContext] = useAtom(verifyContextAtom) const [, setView] = useAtom(viewAtom) - const { data: newsList = [] } = useNewsQuery('전체') const { data: analysis } = useNewsAnalysisQuery(selectedNewsId) - const newsItem = selectedNewsId - ? newsList.find((n) => n.id === selectedNewsId) - : undefined function openVerifyForThisNews() { - if (!analysis || !newsItem) return + if (!analysis) return setVerifyContext({ - label: newsItem.title, + label: analysis.title, names: [analysis.main.name, ...analysis.related.map((r) => r.name)], }) setView('verify') @@ -31,7 +27,7 @@ export default function AnalysisPanel() {

영향 분석

{analysis - ? `${newsItem?.sector ?? ''} · 영향 종목 ${analysis.related.length + 1}개` + ? `${analysis.sector} · 영향 종목 ${analysis.related.length + 1}개` : '뉴스 선택 대기 중'}

diff --git a/src/components/news/MobileNewsBar.tsx b/src/components/news/MobileNewsBar.tsx index 4494902..899c7ac 100644 --- a/src/components/news/MobileNewsBar.tsx +++ b/src/components/news/MobileNewsBar.tsx @@ -13,13 +13,21 @@ export default function MobileNewsBar() { const [selectedId, setSelectedId] = useAtom(selectedNewsIdAtom) const [sheetOpen, setSheetOpen] = useAtom(newsSheetOpenAtom) - const { data: list = [] } = useNewsQuery(sector) + const { data, fetchNextPage, hasNextPage } = useNewsQuery(sector) + const list = data?.pages.flatMap((p) => p.items) ?? [] const index = list.findIndex((n) => n.id === selectedId) const current = index >= 0 ? list[index] : null - function step(delta: number) { + async function step(delta: number) { const next = index + delta - if (next < 0 || next >= list.length) return + if (next < 0) return + if (next >= list.length) { + if (!hasNextPage) return + const result = await fetchNextPage() + const freshList = result.data?.pages.flatMap((p) => p.items) ?? list + if (next < freshList.length) setSelectedId(freshList[next].id) + return + } setSelectedId(list[next].id) } @@ -61,7 +69,7 @@ export default function MobileNewsBar() {