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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 31 additions & 19 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@ pnpm dlx shadcn@latest add <component>
Both `#/*` and `@/*` resolve to `./src/*`:

```typescript
import { cn } from '#/lib/utils'
import { cn } from '@/lib/utils'
import { cn } from '#/shared/utils/cn'
import { cn } from '@/shared/utils/cn'
```

## Architecture
Expand All @@ -53,26 +53,38 @@ Routes are defined in `src/routes/`. The route tree is auto-generated to `src/ro
- `index.tsx` - Home page
- `demo/` - Demo pages (can be safely deleted)

### Key Directories (Planned Structure)
### Key Directories

Category-first, then domain: each top-level concern (`components`, `hooks`, `utils`, `constants`, `types`, `apis`, `store`, `mocks`) has its own folder, and within each, code is split by domain (`news`, `graph`, `analysis`, `verify`, `scrap`, `cover`). Code used by 2+ domains doesn't live inside a category — it goes in the single top-level `src/shared/` folder, mirroring the same category subfolders internally. Domain subfolders are only created where a file actually exists for that domain (e.g. `hooks/news/` doesn't exist since there's no news-specific hook).

```
src/
├── routes/ # File-based routes
├── components/
│ ├── ui/ # shadcn components
│ ├── news/ # News list, cards
│ ├── graph/ # React Flow graph components
│ ├── analysis/ # Impact analysis panel
│ ├── verify/ # Prediction verification
│ └── briefing/ # Watchlist briefing sheet
├── lib/
│ ├── utils.ts # cn() utility
│ ├── api.ts # API fetch + Query hooks
│ └── layout.ts # Graph layout calculations
├── routes/ # File-based routes (unchanged by domain split)
├── shared/ # cross-domain code, one subfolder per category
│ ├── components/ # Header
│ ├── hooks/ # useInfiniteScrollTrigger
│ ├── utils/ # cn, format, graphIndex
│ ├── constants/ # tabs
│ ├── types/ # Direction
│ ├── apis/ # http, paginate
│ ├── store/ # viewAtom, selectedNewsIdAtom, ...
│ └── mocks/ # aggregated handlers, browser worker
├── components/{news,graph,analysis,verify,scrap,cover,ui}/
│ └── ui/ # shadcn components — kept flat here, not under shared/,
│ # since `pnpm dlx shadcn add` targets this path by default
├── hooks/graph/ # usePropagation
├── utils/graph/ # layout.ts
├── constants/{graph,verify}/
├── types/{news,graph,analysis,verify}/
├── apis/{news,graph,analysis,verify}/ # mock.ts + queries.ts (+ mappers.ts) per domain
├── store/{news,graph,verify,scrap}/ # Jotai atoms, one file per domain
├── mocks/{news,graph,analysis,verify}/ # MSW handlers + mock data per domain
├── integrations/tanstack-query/ # Query provider setup
── types/
── main.tsx, router.tsx, styles.css
```

There is no `briefing` domain yet — none of the above folders have a `briefing/` subfolder until that feature is built.

### State Management Pattern

- **Server state**: TanStack Query only (news, graph, analysis, verification data)
Expand All @@ -84,7 +96,7 @@ src/
When implementing the graph visualization:

1. **Declare nodeTypes/edgeTypes outside components** - Defining inside causes full graph remount on every render
2. **No layout library needed** - Use simple trigonometry for radial/cluster layouts in `lib/layout.ts`
2. **No layout library needed** - Use simple trigonometry for radial/cluster layouts in `utils/graph/layout.ts`
3. **Custom floating edges required** - Default edges snap to handles; calculate rectangle intersection points for proper connections
4. **One-shot animations only** - Never use React Flow's `animated: true`; use CSS with `animation-fill-mode: forwards`
5. **Cache fullLayout results** - Recalculating on view switches causes nodes to jump
Expand All @@ -103,7 +115,7 @@ Base path is `/api`. No authentication. All endpoints wrapped with TanStack Quer

- `GET /api/news` - News list, cursor-paginated (`sector?`, `cursor?`, `limit?` → `{ items, nextCursor }`), infinite scroll
- `GET /api/news/{id}/analysis` - Impact analysis text panel (article summary, main stock, related stocks, rationale). Includes `title`/`sector` so the panel doesn't need the paginated list. No graph/coordinate data — see the next endpoint
- `GET /api/news/{id}/graph` - Propagation subgraph for this news (`{ newsId, originId, nodes, edges }`), Neo4j-derived. Nodes carry `direction` only where relevant; no coordinates — the frontend computes hop-based layout itself (`lib/graphIndex.ts`'s `bfsBuild` + `lib/layout.ts`'s `radialLayout`)
- `GET /api/news/{id}/graph` - Propagation subgraph for this news (`{ newsId, originId, nodes, edges }`), Neo4j-derived. Nodes carry `direction` only where relevant; no coordinates — the frontend computes hop-based layout itself (`shared/utils/graphIndex.ts`'s `bfsBuild` + `utils/graph/layout.ts`'s `radialLayout`)
- `GET /api/graph` - Full ontology graph, same trimmed node/edge shape as above (`kind: 'STOCK' | 'CONCEPT'`, edges carry `relationType` only for competitor/affiliate-type relations)
- `POST /api/briefing` - Watchlist reverse lookup
- `GET /api/verify` - Prediction verification data; `news` is cursor-paginated the same way as `/api/news`, `daily` is not
Expand All @@ -116,5 +128,5 @@ Base path is `/api`. No authentication. All endpoints wrapped with TanStack Quer
- React Flow `animated: true`
- `nodeTypes`/`edgeTypes` declared inside components
- Horizontal scroll news list on mobile
- Numbered pagination UI (1, 2, 3…) in news list or verification screen — both use cursor-based infinite scroll instead (`useInfiniteScrollTrigger`)
- Numbered pagination UI (1, 2, 3…) in news list or verification screen — both use cursor-based infinite scroll instead (`shared/hooks/useInfiniteScrollTrigger`)
- shadcn Card inside graph nodes (interferes with size calculation)
46 changes: 8 additions & 38 deletions src/lib/mappers.ts → src/apis/analysis/mappers.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,14 @@
import type {
Direction,
NewsImpact,
NewsImpactGraph,
NewsListItem,
NewsListPage,
} from '#/types'
import type { Direction } from '#/shared/types'
import type { NewsImpact, OriginStock, RelatedStock } from '#/types/analysis'
import type { NewsImpactGraph } from '#/types/graph'

/**
* 실 백엔드가 내려줄 wire(snake_case) 응답 타입 + 프론트 도메인 타입(camelCase)으로의
* 변환 함수 모음. lib/api.ts(목 데이터)와 MSW 핸들러는 이 wire 타입 그대로를 다루고,
* lib/queries.ts의 fetch 함수가 여기를 거쳐 컴포넌트에 camelCase 도메인 타입을 넘긴다.
* 실 백엔드가 붙을 때도 이 매퍼만 유지하면 컴포넌트 쪽은 손댈 필요가 없다.
* 변환 함수 모음. apis/analysis/mock.ts(목 데이터)와 mocks/analysis/handlers.ts는 이 wire
* 타입 그대로를 다루고, apis/analysis/queries.ts의 fetch 함수가 여기를 거쳐 컴포넌트에
* camelCase 도메인 타입을 넘긴다.
*/

export interface NewsListItemWire {
news_id: number
title: string
press: string
published_at: string
}

export interface NewsListPageWire {
items: NewsListItemWire[]
nextCursor: string | null
}

export type StockStatus = 'up' | 'down'

export interface OriginStockWire {
Expand Down Expand Up @@ -56,20 +40,6 @@ function toDirection(status: StockStatus): Direction {
return status === 'up' ? 'UP' : 'DOWN'
}

export function mapNewsListPage(wire: NewsListPageWire): NewsListPage {
return {
items: wire.items.map(
(item): NewsListItem => ({
id: item.news_id,
title: item.title,
press: item.press,
publishedAt: item.published_at,
}),
),
nextCursor: wire.nextCursor,
}
}

/** newsId는 wire 응답에 담기지 않는다(그래프 안의 graph.newsId만 존재) — 요청한 id를 그대로 채운다. */
export function mapNewsImpact(newsId: number, wire: NewsImpactWire): NewsImpact {
return {
Expand All @@ -80,13 +50,13 @@ export function mapNewsImpact(newsId: number, wire: NewsImpactWire): NewsImpact
publishedAt: wire.source.published_at,
url: wire.source.url,
},
originStocks: wire.origin_stocks.map((o) => ({
originStocks: wire.origin_stocks.map((o): OriginStock => ({
ticker: o.ticker,
name: o.name,
direction: toDirection(o.status),
reason: o.reason,
})),
relatedStocks: wire.related_stocks.map((r) => ({
relatedStocks: wire.related_stocks.map((r): RelatedStock => ({
ticker: r.ticker,
name: r.name,
direction: toDirection(r.status),
Expand Down
135 changes: 9 additions & 126 deletions src/lib/api.ts → src/apis/analysis/mock.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,12 @@
import {
ONTOLOGY_EDGES,
ONTOLOGY_NODES,
NEWS_RECORDS,
VERIFY_ENTRIES,
buildVerifyDaily,
pullRefreshBatch,
} from './data'
import type { NewsImpactWire, NewsListPageWire } from './mappers'
import type {
Direction,
ImpactGraphNode,
OntologyEdge,
OntologyNode,
VerifyResponse,
} from '#/types'
import { NEWS_RECORDS } from '#/mocks/news/data'
import { ONTOLOGY_EDGES, ONTOLOGY_NODES } from '#/mocks/graph/data'
import type { NewsImpactWire } from './mappers'
import type { Direction } from '#/shared/types'
import type { ImpactGraphNode, OntologyEdge, OntologyNode } from '#/types/graph'

/**
* `docs/KOSLINK-FRONTEND.md` §5 API 명세와 같은 시그니처를 갖는 헬퍼 모음.
* 지금은 백엔드가 없어 lib/data.ts를 동기적으로 가공해 반환한다. 반환 모양은 실
* 백엔드가 내려줄 wire(snake_case) 그대로다 — MSW 핸들러가 이 함수들을 그대로
* HttpResponse.json()으로 흘려보내고, lib/queries.ts가 lib/mappers.ts를 거쳐
* camelCase 도메인 타입으로 바꾼다. 실 API가 준비되면 이 함수들의 본문만 fetch
* 호출로 바꾸면 되고, 매퍼·호출부는 그대로 둘 수 있다.
* `docs/KOSLINK-FRONTEND.md` §5 API 명세와 같은 시그니처를 갖는 헬퍼. 지금은 백엔드가
* 없어 mocks/news/data.ts + mocks/graph/data.ts를 동기적으로 가공해 반환한다.
*/

const nodeById = new Map(ONTOLOGY_NODES.map((n) => [n.id, n]))
Expand All @@ -32,65 +17,6 @@ function requireNode(id: string): OntologyNode {
return node
}

/**
* 목록 커서 페이징 공통 로직. cursor는 "마지막으로 받은 항목의 커서 키" 관례를
* 쓰지만, 클라이언트는 이 값을 해석하지 않고 nextCursor를 그대로 되돌려주기만
* 한다. GET /api/news, GET /api/verify가 함께 쓴다.
*/
function paginate<T>(
items: T[],
cursor: string | undefined,
limit: number,
cursorOf: (item: T) => string,
): { page: T[]; nextCursor: string | null } {
const startIndex = cursor
? Math.max(items.findIndex((item) => cursorOf(item) === cursor) + 1, 0)
: 0
const page = items.slice(startIndex, startIndex + limit)
const last = page.at(-1)
const nextCursor =
startIndex + limit < items.length && last ? cursorOf(last) : null
return { page, nextCursor }
}

export interface GetNewsParams {
/** 이전 응답의 nextCursor. 첫 페이지는 생략한다. */
cursor?: string
limit?: number
}

const DEFAULT_NEWS_LIMIT = 20

export function getNews({
cursor,
limit = DEFAULT_NEWS_LIMIT,
}: GetNewsParams = {}): NewsListPageWire {
const { page, nextCursor } = paginate(NEWS_RECORDS, cursor, limit, (n) =>
String(n.id),
)

return {
items: page.map((n) => ({
news_id: n.id,
title: n.title,
press: n.press,
published_at: n.publishedAt,
})),
nextCursor,
}
}

/**
* "최신 뉴스 새로고침" 데모용 — 실 백엔드가 없어 pullRefreshBatch()로 몇 건을
* 새 뉴스로 뽑아 NEWS_RECORDS 맨 앞에 끼워 넣고, 그 id 목록을 돌려준다.
* 호출부(NewsPanel)는 이 id로 새로 추가된 카드에 NEW 배지를 붙이고 몇 건이
* 들어왔는지 안내한다.
*/
export function refreshNews(): { addedIds: number[] } {
const added = pullRefreshBatch()
return { addedIds: added.map((n) => n.id) }
}

function findOntologyEdge(a: string, b: string): OntologyEdge | undefined {
return ONTOLOGY_EDGES.find(
(e) =>
Expand All @@ -117,8 +43,8 @@ function buildFinalSummary(originNames: string[], relatedCount: number): string
}

/**
* GET /api/news/{id}/impact. 기존 analysis + graph 두 엔드포인트를 하나로 합친 것 —
* 분석 패널과 그래프 패널이 같은 화면 전환에서 동시에 필요한 데이터라서다.
* GET /api/news/{id}/impact. 분석 패널과 그래프 패널이 같은 화면 전환에서 동시에
* 필요한 데이터를 하나로 합쳐 내려준다.
*/
export function getNewsImpact(newsId: number): NewsImpactWire | null {
const record = NEWS_RECORDS.find((n) => n.id === newsId)
Expand Down Expand Up @@ -199,46 +125,3 @@ export function getNewsImpact(newsId: number): NewsImpactWire | null {
},
}
}

export interface GetGraphParams {
mode: 'full'
}

export function getGraph(
_params?: GetGraphParams,
): { nodes: OntologyNode[]; edges: OntologyEdge[] } {
return { nodes: ONTOLOGY_NODES, edges: ONTOLOGY_EDGES }
}

function bySector<T extends { sector: string }>(
items: T[],
sector?: string,
): T[] {
return items.filter(
(n) => !sector || sector === '전체' || n.sector === sector,
)
}

export interface GetVerifyParams {
sector?: string
/** 이전 응답의 nextCursor. 첫 페이지는 생략한다. */
cursor?: string
limit?: number
}

const DEFAULT_VERIFY_LIMIT = 10

export function getVerify({
sector,
cursor,
limit = DEFAULT_VERIFY_LIMIT,
}: GetVerifyParams = {}): VerifyResponse {
const { page, nextCursor } = paginate(
bySector(VERIFY_ENTRIES, sector),
cursor,
limit,
(v) => v.newsId,
)
// daily 추이는 섹터/페이지와 무관한 전체 집계라 페이징하지 않는다.
return { daily: buildVerifyDaily(), news: page, nextCursor }
}
29 changes: 29 additions & 0 deletions src/apis/analysis/queries.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { useQuery } from '@tanstack/react-query'
import { http } from '#/shared/apis/http'
import { getNewsImpact } from './mock'
import { mapNewsImpact } from './mappers'
import type { NewsImpactWire } from './mappers'
import type { NewsImpact } from '#/types/analysis'

export const analysisKeys = {
impact: (newsId: number) => ['news', newsId, 'impact'] as const,
}

async function fetchNewsImpact(newsId: number): Promise<NewsImpact> {
const wire = await http.get(`news/${newsId}/impact`).json<NewsImpactWire>()
return mapNewsImpact(newsId, wire)
}

/** 뉴스 영향 분석 + 파급 경로 그래프 — 분석 패널과 그래프 패널이 같은 쿼리 키를 공유해 한 번만 조회한다. */
export function useNewsImpactQuery(newsId: number | null) {
return useQuery({
queryKey: analysisKeys.impact(newsId ?? -1),
queryFn: () => fetchNewsImpact(newsId as number),
enabled: newsId != null,
placeholderData: () => {
if (newsId == null) return undefined
const wire = getNewsImpact(newsId)
return wire ? mapNewsImpact(newsId, wire) : undefined
},
})
}
12 changes: 12 additions & 0 deletions src/apis/graph/mock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { ONTOLOGY_EDGES, ONTOLOGY_NODES } from '#/mocks/graph/data'
import type { OntologyEdge, OntologyNode } from '#/types/graph'

export interface GetGraphParams {
mode: 'full'
}

export function getGraph(
_params?: GetGraphParams,
): { nodes: OntologyNode[]; edges: OntologyEdge[] } {
return { nodes: ONTOLOGY_NODES, edges: ONTOLOGY_EDGES }
}
24 changes: 24 additions & 0 deletions src/apis/graph/queries.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { useQuery } from '@tanstack/react-query'
import { http } from '#/shared/apis/http'
import { getGraph } from './mock'
import type { OntologyEdge, OntologyNode } from '#/types/graph'

export const graphKeys = {
all: () => ['graph'] as const,
}

async function fetchGraph(): Promise<{
nodes: OntologyNode[]
edges: OntologyEdge[]
}> {
return http.get('graph', { searchParams: { mode: 'full' } }).json()
}

export function useGraphQuery() {
return useQuery({
queryKey: graphKeys.all(),
queryFn: fetchGraph,
placeholderData: () => getGraph({ mode: 'full' }),
staleTime: Infinity,
})
}
Loading