diff --git a/frontend/docs/mustpass/storybook-stories.md b/frontend/docs/mustpass/storybook-stories.md index 6abf737..8723ab5 100644 --- a/frontend/docs/mustpass/storybook-stories.md +++ b/frontend/docs/mustpass/storybook-stories.md @@ -23,6 +23,5 @@ ## 범위 제외 (이번 회차 대상 아님) -- [ ] `useFlow`(stackflow) 의존 컴포넌트는 provider 데코레이터가 필요해 제외: `home/relation-force-map`, `timeline/timeline-event-card`, `person/person-page-header`. - [ ] 시각 변화가 없거나 부모 레이아웃 없이는 무의미: `ui/label`, `ui/step-slide.ts`, `ui/tag-chip.ts`, `timeline/timeline-scroll-shell`. - [ ] `ui/button`은 이미 스토리 보유. diff --git a/frontend/src/components/home/relation-force-map.stories.tsx b/frontend/src/components/home/relation-force-map.stories.tsx new file mode 100644 index 0000000..5809329 --- /dev/null +++ b/frontend/src/components/home/relation-force-map.stories.tsx @@ -0,0 +1,118 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import { RelationForceMap } from '@/components/home/relation-force-map' +import type { + MeNode, + PersonNode, + RelationEdge, +} from '@/apis/generated/mongle-api.schemas' + +const ME: MeNode = { + label: '나', + id: '00000000-0000-0000-0000-000000000000', + name: '몽글', + profileImageUrl: undefined, + avatarGender: 'FEMALE', +} + +// 관계태그(relationTags) 첫 칩이 노드의 카테고리(색·범례)를 결정한다. +const NODES: PersonNode[] = [ + { + id: 1, + name: '엄마', + profileImageUrl: undefined, + avatarGender: 'FEMALE', + favorite: true, + recordCount: 24, + relationTags: [{ id: 100, label: '가족', color: '#2f6eea' }], + intimacy: { + status: 'NORMAL', + averageIntervalDays: 14, + daysSinceLastMeet: 3, + }, + firstMetDate: '2019-01-01', + }, + { + id: 2, + name: '김민수', + profileImageUrl: undefined, + avatarGender: 'MALE', + favorite: false, + recordCount: 12, + relationTags: [{ id: 101, label: '친구', color: '#28b945' }], + intimacy: { + status: 'NORMAL', + averageIntervalDays: 30, + daysSinceLastMeet: 12, + }, + firstMetDate: '2021-03-10', + }, + { + id: 3, + name: '이지은', + profileImageUrl: undefined, + avatarGender: 'FEMALE', + favorite: false, + recordCount: 8, + relationTags: [{ id: 101, label: '친구', color: '#28b945' }], + intimacy: { + status: 'DISTANT', + averageIntervalDays: 45, + daysSinceLastMeet: 90, + }, + firstMetDate: '2022-06-11', + }, + { + id: 4, + name: '박대리', + profileImageUrl: undefined, + avatarGender: 'MALE', + favorite: false, + recordCount: 5, + relationTags: [{ id: 102, label: '직장', color: '#ff8a00' }], + intimacy: { + status: 'UNKNOWN', + averageIntervalDays: undefined, + daysSinceLastMeet: undefined, + }, + firstMetDate: '2023-09-01', + }, +] + +const EDGES: RelationEdge[] = [ + { personId: 1, distant: false }, + { personId: 2, distant: false }, + { personId: 3, distant: true }, + { personId: 4, distant: false }, +] + +const meta = { + title: 'Home/RelationForceMap', + component: RelationForceMap, + tags: ['autodocs'], + args: { + me: ME, + nodes: NODES, + edges: EDGES, + onSelectPerson: () => {}, + }, +} satisfies Meta + +export default meta + +type Story = StoryObj + +export const Default: Story = {} + +export const SinglePerson: Story = { + args: { + nodes: [NODES[0]], + edges: [EDGES[0]], + }, +} + +export const Empty: Story = { + args: { + nodes: [], + edges: [], + }, +} diff --git a/frontend/src/components/home/relation-force-map.tsx b/frontend/src/components/home/relation-force-map.tsx index 48512c7..0d643de 100644 --- a/frontend/src/components/home/relation-force-map.tsx +++ b/frontend/src/components/home/relation-force-map.tsx @@ -1,4 +1,3 @@ -import { useFlow } from '@stackflow/react' import { RotateCcw, ZoomIn, ZoomOut } from 'lucide-react' import { useEffect, useMemo, useRef, useState } from 'react' import { optimizedImageUrl } from '@/lib/image-url' @@ -59,12 +58,13 @@ export function RelationForceMap({ me, nodes, edges: _edges, + onSelectPerson, }: { me: RelationMapResponse['me'] nodes: RelationMapResponse['nodes'] edges: RelationMapResponse['edges'] + onSelectPerson: (personId: number) => void }) { - const { push } = useFlow() const pointerPositionsRef = useRef( new Map(), ) @@ -118,7 +118,7 @@ export function RelationForceMap({ return } - push('Person', { personId: String(personId), view: 'timeline' }) + onSelectPerson(personId) } useEffect(() => { diff --git a/frontend/src/components/person/person-page-header.stories.tsx b/frontend/src/components/person/person-page-header.stories.tsx new file mode 100644 index 0000000..72d3019 --- /dev/null +++ b/frontend/src/components/person/person-page-header.stories.tsx @@ -0,0 +1,41 @@ +import { useState } from 'react' +import type { Meta, StoryObj } from '@storybook/react-vite' +import { PersonPageHeader } from '@/components/person/person-page-header' +import type { PersonView } from '@/stackflow/stackflow.config' + +function PersonPageHeaderDemo({ initial }: { initial: PersonView }) { + const [active, setActive] = useState(initial) + return ( +
+ {}} + /> +
+ ) +} + +const meta = { + title: 'Person/PersonPageHeader', + component: PersonPageHeader, + tags: ['autodocs'], + args: { + active: 'profile', + onSelectView: () => {}, + onBack: () => {}, + }, + render: (args) => , +} satisfies Meta + +export default meta + +type Story = StoryObj + +export const ProfileActive: Story = { + args: { active: 'profile' }, +} + +export const TimelineActive: Story = { + args: { active: 'timeline' }, +} diff --git a/frontend/src/components/person/person-page-header.tsx b/frontend/src/components/person/person-page-header.tsx index b7683ef..0c8aea9 100644 --- a/frontend/src/components/person/person-page-header.tsx +++ b/frontend/src/components/person/person-page-header.tsx @@ -1,4 +1,3 @@ -import { useFlow } from '@stackflow/react' import { BackButton } from '@/components/layout/back-button' import { PersonTabNav } from '@/components/person/person-tab-nav' import type { PersonView } from '@/stackflow/stackflow.config' @@ -6,15 +5,15 @@ import type { PersonView } from '@/stackflow/stackflow.config' export function PersonPageHeader({ active, onSelectView, + onBack, }: { active: PersonView onSelectView: (view: PersonView) => void + onBack: () => void }) { - const { pop } = useFlow() - return (
- pop()} className="mb-4" /> +
) diff --git a/frontend/src/components/timeline/timeline-event-card.stories.tsx b/frontend/src/components/timeline/timeline-event-card.stories.tsx new file mode 100644 index 0000000..587057b --- /dev/null +++ b/frontend/src/components/timeline/timeline-event-card.stories.tsx @@ -0,0 +1,57 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import { TimelineEventCard } from '@/components/timeline/timeline-event-card' +import type { TimelineEventCardItem } from '@/components/timeline/timeline-event-card' + +const FULL_ITEM: TimelineEventCardItem = { + id: 1, + title: '엄마와 봄나들이', + memo: '오랜만에 벚꽃 구경을 갔다. 김밥을 싸서 한강에서 하루 종일 걸었다.', + occurredDate: '2026-04-05', + category: { id: 10, label: '나들이' }, + persons: [ + { id: 3, name: '엄마', profileImageUrl: undefined, favorite: true }, + { id: 4, name: '이모', profileImageUrl: undefined, favorite: false }, + ], + emotions: [ + { id: 20, label: '설렘' }, + { id: 21, label: '평온' }, + ], + photoUrls: [ + 'https://picsum.photos/seed/mongle1/256', + 'https://picsum.photos/seed/mongle2/256', + ], +} + +const MINIMAL_ITEM: TimelineEventCardItem = { + id: 2, + title: '혼자 마신 커피', + occurredDate: '2026-03-18', +} + +const meta = { + title: 'Timeline/TimelineEventCard', + component: TimelineEventCard, + tags: ['autodocs'], + args: { + item: FULL_ITEM, + onSelect: () => {}, + }, + // 카드는 flex-1 버튼이라 부모 폭이 있어야 레이아웃이 드러난다. + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta + +export default meta + +type Story = StoryObj + +export const Full: Story = {} + +export const Minimal: Story = { + args: { item: MINIMAL_ITEM }, +} diff --git a/frontend/src/components/timeline/timeline-event-card.test.tsx b/frontend/src/components/timeline/timeline-event-card.test.tsx index 6a178ce..ad0fb4a 100644 --- a/frontend/src/components/timeline/timeline-event-card.test.tsx +++ b/frontend/src/components/timeline/timeline-event-card.test.tsx @@ -1,12 +1,8 @@ -import { cleanup, render, screen } from '@testing-library/react' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { TimelineEventCard } from './timeline-event-card' import type { TimelineEventCardItem } from './timeline-event-card' -vi.mock('@stackflow/react', () => ({ - useFlow: () => ({ push: vi.fn() }), -})) - const item: TimelineEventCardItem = { id: 1, title: '비밀 회동', @@ -27,7 +23,7 @@ describe('TimelineEventCard Amplitude 마스킹', () => { afterEach(cleanup) it('제목·메모·카테고리·감정·사람 라벨이 모두 data-amp-mask 아래에 렌더된다', () => { - render() + render() expectMasked('비밀 회동') expectMasked('민감한 메모 내용') @@ -36,3 +32,16 @@ describe('TimelineEventCard Amplitude 마스킹', () => { expectMasked('김몽글') }) }) + +describe('TimelineEventCard 선택 콜백', () => { + afterEach(cleanup) + + it('카드를 누르면 onSelect가 기록 id로 호출된다', () => { + const onSelect = vi.fn() + render() + + fireEvent.click(screen.getByRole('button')) + + expect(onSelect).toHaveBeenCalledWith(item.id) + }) +}) diff --git a/frontend/src/components/timeline/timeline-event-card.tsx b/frontend/src/components/timeline/timeline-event-card.tsx index 8421ad2..1cc97f2 100644 --- a/frontend/src/components/timeline/timeline-event-card.tsx +++ b/frontend/src/components/timeline/timeline-event-card.tsx @@ -1,4 +1,3 @@ -import { useFlow } from '@stackflow/react' import { Badge } from '@/components/ui/badge' import { Card, CardContent } from '@/components/ui/card' import { MonogramAvatar } from '@/components/ui/monogram-avatar' @@ -84,8 +83,13 @@ function TimelinePhotoPreview({ photoUrls }: { photoUrls: string[] }) { ) } -export function TimelineEventCard({ item }: { item: TimelineEventCardItem }) { - const { push } = useFlow() +export function TimelineEventCard({ + item, + onSelect, +}: { + item: TimelineEventCardItem + onSelect: (eventId: number) => void +}) { const persons = item.persons ?? [] const photoUrls = item.photoUrls ?? [] const memo = item.memo?.trim() ?? '' @@ -93,7 +97,7 @@ export function TimelineEventCard({ item }: { item: TimelineEventCardItem }) { return (