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
1 change: 0 additions & 1 deletion frontend/docs/mustpass/storybook-stories.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`은 이미 스토리 보유.
118 changes: 118 additions & 0 deletions frontend/src/components/home/relation-force-map.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof RelationForceMap>

export default meta

type Story = StoryObj<typeof meta>

export const Default: Story = {}

export const SinglePerson: Story = {
args: {
nodes: [NODES[0]],
edges: [EDGES[0]],
},
}

export const Empty: Story = {
args: {
nodes: [],
edges: [],
},
}
6 changes: 3 additions & 3 deletions frontend/src/components/home/relation-force-map.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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<number, { x: number; y: number }>(),
)
Expand Down Expand Up @@ -118,7 +118,7 @@ export function RelationForceMap({
return
}

push('Person', { personId: String(personId), view: 'timeline' })
onSelectPerson(personId)
}

useEffect(() => {
Expand Down
41 changes: 41 additions & 0 deletions frontend/src/components/person/person-page-header.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<PersonView>(initial)
return (
<div className="max-w-md">
<PersonPageHeader
active={active}
onSelectView={setActive}
onBack={() => {}}
/>
</div>
)
}

const meta = {
title: 'Person/PersonPageHeader',
component: PersonPageHeader,
tags: ['autodocs'],
args: {
active: 'profile',
onSelectView: () => {},
onBack: () => {},
},
render: (args) => <PersonPageHeaderDemo initial={args.active} />,
} satisfies Meta<typeof PersonPageHeader>

export default meta

type Story = StoryObj<typeof meta>

export const ProfileActive: Story = {
args: { active: 'profile' },
}

export const TimelineActive: Story = {
args: { active: 'timeline' },
}
7 changes: 3 additions & 4 deletions frontend/src/components/person/person-page-header.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,19 @@
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'

export function PersonPageHeader({
active,
onSelectView,
onBack,
}: {
active: PersonView
onSelectView: (view: PersonView) => void
onBack: () => void
}) {
const { pop } = useFlow()

return (
<header className="shrink-0 pb-4">
<BackButton onClick={() => pop()} className="mb-4" />
<BackButton onClick={onBack} className="mb-4" />
<PersonTabNav active={active} onSelect={onSelectView} />
</header>
)
Expand Down
57 changes: 57 additions & 0 deletions frontend/src/components/timeline/timeline-event-card.stories.tsx
Original file line number Diff line number Diff line change
@@ -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) => (
<div className="flex max-w-md">
<Story />
</div>
),
],
} satisfies Meta<typeof TimelineEventCard>

export default meta

type Story = StoryObj<typeof meta>

export const Full: Story = {}

export const Minimal: Story = {
args: { item: MINIMAL_ITEM },
}
21 changes: 15 additions & 6 deletions frontend/src/components/timeline/timeline-event-card.test.tsx
Original file line number Diff line number Diff line change
@@ -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: '비밀 회동',
Expand All @@ -27,7 +23,7 @@ describe('TimelineEventCard Amplitude 마스킹', () => {
afterEach(cleanup)

it('제목·메모·카테고리·감정·사람 라벨이 모두 data-amp-mask 아래에 렌더된다', () => {
render(<TimelineEventCard item={item} />)
render(<TimelineEventCard item={item} onSelect={vi.fn()} />)

expectMasked('비밀 회동')
expectMasked('민감한 메모 내용')
Expand All @@ -36,3 +32,16 @@ describe('TimelineEventCard Amplitude 마스킹', () => {
expectMasked('김몽글')
})
})

describe('TimelineEventCard 선택 콜백', () => {
afterEach(cleanup)

it('카드를 누르면 onSelect가 기록 id로 호출된다', () => {
const onSelect = vi.fn()
render(<TimelineEventCard item={item} onSelect={onSelect} />)

fireEvent.click(screen.getByRole('button'))

expect(onSelect).toHaveBeenCalledWith(item.id)
})
})
12 changes: 8 additions & 4 deletions frontend/src/components/timeline/timeline-event-card.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -84,16 +83,21 @@ 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() ?? ''

return (
<button
type="button"
onClick={() => push('EventDetail', { eventId: String(item.id) })}
onClick={() => onSelect(item.id)}
className="block min-w-0 flex-1 text-left"
>
<Card className="relative overflow-hidden py-0 shadow-[0_10px_30px_rgba(0,0,0,0.045)] transition-all hover:-translate-y-0.5 hover:bg-muted/20 hover:shadow-[0_14px_36px_rgba(0,0,0,0.07)]">
Expand Down
9 changes: 7 additions & 2 deletions frontend/src/stackflow/activities/person-activity.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useStepFlow } from '@stackflow/react'
import { useFlow, useStepFlow } from '@stackflow/react'
import type { ActivityComponentType } from '@stackflow/react'
import { AnimatePresence, motion, useReducedMotion } from 'motion/react'
import { PersonPageHeader } from '@/components/person/person-page-header'
Expand All @@ -20,6 +20,7 @@ const VIEW_ORDER: readonly PersonView[] = ['profile', 'timeline']
// 헤더(뒤로가기+탭)는 activity에 고정하고 그 아래 콘텐츠만 탭 순서 방향으로 밀어낸다.
export const PersonActivity: ActivityComponentType<'Person'> = ({ params }) => {
const view: PersonView = params.view === 'timeline' ? 'timeline' : 'profile'
const { pop } = useFlow()
const { replaceStep } = useStepFlow('Person')
const reducedMotion = useReducedMotion()
const direction = useStepSlideDirection(view, VIEW_ORDER)
Expand All @@ -35,7 +36,11 @@ export const PersonActivity: ActivityComponentType<'Person'> = ({ params }) => {

return (
<ActivityShell layout="fixed">
<PersonPageHeader active={view} onSelectView={handleSelectView} />
<PersonPageHeader
active={view}
onSelectView={handleSelectView}
onBack={() => pop()}
/>
<div className="relative min-h-0 min-w-0 flex-1 overflow-x-clip">
<AnimatePresence initial={false} custom={direction}>
<motion.div
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,12 @@ export function PersonTimelineView({ personId }: { personId: string }) {
scrollRootRef={scrollRef}
items={events}
renderCard={(event) => (
<TimelineEventCard item={fromEventResponse(event)} />
<TimelineEventCard
item={fromEventResponse(event)}
onSelect={(eventId) =>
push('EventDetail', { eventId: String(eventId) })
}
/>
)}
/>
)}
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/stackflow/tabs/home-tab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,9 @@ export function HomeTab() {
me={mapData.me}
nodes={graphNodes}
edges={visibleEdges}
onSelectPerson={(id) =>
push('Person', { personId: String(id), view: 'timeline' })
}
/>
)}

Expand Down
Loading
Loading