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
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,12 @@ VITE_API_BASE_URL=http://localhost:8080
# MSW Mock 활성화 (true: mock 사용, false 또는 미설정: 실제 API 사용)
VITE_USE_MOCK=true

# PostHog 공개 프로젝트 토큰 (개인 API 키 사용 금지)
VITE_POSTHOG_PROJECT_TOKEN=
VITE_POSTHOG_HOST=https://us.i.posthog.com

# 기본값: 프로덕션에서만 수집. 개발/mock 검증 시 true, 완전 비활성화 시 false
VITE_POSTHOG_ENABLED=

# Google Analytics 4 Measurement ID
VITE_GA_MEASUREMENT_ID=G-JGZ8JFGKPW
3 changes: 3 additions & 0 deletions .env.production
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# 공개 수집 전용 토큰. 개인 API 키가 아니며 Vercel 환경 변수로 덮어쓸 수 있습니다.
VITE_POSTHOG_PROJECT_TOKEN=phc_uS3bb4jEAeVMZVPT6aEM4ztfQ6A3wjTrWGkbENgSMSyG
VITE_POSTHOG_HOST=https://us.i.posthog.com
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
- 다크 테마 기본 적용
- FSD(Feature-Sliced Design) 아키텍처 기반 구조화
- Vercel Speed Insights로 실사용자 성능 지표 수집
- PostHog로 페이지 방문과 로그인 사용자별 사용 흐름 수집

---

Expand Down Expand Up @@ -204,6 +205,28 @@ VITE_GA_MEASUREMENT_ID=G-JGZ8JFGKPW
> `VITE_USE_MOCK=true`에서는 `auth`, `members`, `attendance`, `calendar`, `operations`, `chat`, `drive` 핸들러를 함께 등록합니다.
> 이메일 인증이나 실서비스 백엔드에 묶이지 않고 관리자 출퇴근 예외 처리, 지각비 정산, 야간 근무 표시까지 로컬에서 점검할 수 있습니다.

### PostHog

[이슈 #420](https://github.com/Yanus306/yanus-groupware/issues/420)에서 제품 분석 연동을 관리합니다.
[yANUs 프로젝트 설정](https://us.posthog.com/project/644476/settings/project-details)에서 공개 프로젝트 토큰을 확인합니다.

```env
VITE_POSTHOG_PROJECT_TOKEN=<public-project-token>
VITE_POSTHOG_HOST=https://us.i.posthog.com
```

- 토큰이 없으면 수집하지 않습니다. 개인 API 키를 `VITE_` 변수에 넣지 않습니다.
- 기본적으로 프로덕션 빌드에서만 수집하며, `VITE_USE_MOCK=true` 환경에서는 비활성화합니다.
- 로컬에서 실제 수신을 확인할 때만 `VITE_POSTHOG_ENABLED=true`를 지정합니다. `false`는 수집을 완전히 끕니다.
- 페이지 경로가 바뀔 때 `$pageview`를 수집하고, 로그인 시 사용자 ID만 연결합니다. 로그아웃·계정 변경 시 이전 식별을 초기화합니다.
- 허용한 SDK 기술 속성만 전송합니다. URL 쿼리·해시, referrer, 이메일·이름·채팅·문서 내용은 제외합니다. 자동 클릭 수집과 세션 녹화는 꺼져 있습니다.
- `.env.production`에 yANUs 프로젝트의 공개 수집 토큰을 설정하여 배포 빌드에도 적용합니다. 다른 프로젝트를 사용할 때는 Vercel 환경 변수로 덮어씁니다. Vite 변수는 빌드 시 반영되므로 변경 후 새 빌드가 필요합니다.

```bash
# 로컬 수신 확인 (테스트 이벤트가 실제 프로젝트에 저장됨)
VITE_POSTHOG_ENABLED=true npm run dev
```

---

## 테스트
Expand Down
122 changes: 122 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"@vercel/speed-insights": "^2.0.0",
"framer-motion": "^12.35.0",
"lucide-react": "^0.575.0",
"posthog-js": "^1.435.8",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-markdown": "^10.1.0",
Expand Down
21 changes: 21 additions & 0 deletions src/app/AnalyticsObserver.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { useEffect, useRef } from 'react'
import { useLocation } from 'react-router-dom'
import { useApp } from '../features/auth/model'
import { capturePageview, initializeAnalytics, syncAnalyticsUser } from '../shared/lib/analytics'

export function AnalyticsObserver() {
const { pathname } = useLocation()
const { state, isInitializing } = useApp()
const previousPath = useRef<string | null>(null)
const userId = state.currentUser?.id ?? null

useEffect(() => {
if (isInitializing || !initializeAnalytics()) return
syncAnalyticsUser(userId)
if (previousPath.current === pathname) return
previousPath.current = pathname
capturePageview()
}, [isInitializing, pathname, userId])

return null
}
90 changes: 90 additions & 0 deletions src/app/__tests__/AnalyticsObserver.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { StrictMode } from 'react'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter, useNavigate } from 'react-router-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { AnalyticsObserver } from '../AnalyticsObserver'

const analytics = vi.hoisted(() => ({
initializeAnalytics: vi.fn(() => true),
syncAnalyticsUser: vi.fn(),
capturePageview: vi.fn(),
}))
const auth = vi.hoisted(() => ({
state: { currentUser: null },
isInitializing: false,
}))

vi.mock('../../shared/lib/analytics', () => analytics)
vi.mock('../../features/auth/model', () => ({ useApp: () => auth }))

function Navigation() {
const navigate = useNavigate()
return <>
<button onClick={() => navigate('/login?token=private#secret')}>query</button>
<button onClick={() => navigate('/register')}>register</button>
</>
}

function ObserverScenario() {
return <StrictMode>
<MemoryRouter initialEntries={['/login']}>
<AnalyticsObserver />
<Navigation />
</MemoryRouter>
</StrictMode>
}

describe('AnalyticsObserver', () => {
beforeEach(() => {
vi.clearAllMocks()
auth.isInitializing = false
analytics.initializeAnalytics.mockReturnValue(true)
})

it('captures only one initial pageview under StrictMode', () => {
render(<ObserverScenario />)

expect(analytics.capturePageview).toHaveBeenCalledTimes(1)
})

it('captures a pageview when the SPA path changes', async () => {
const user = userEvent.setup()
render(<ObserverScenario />)
analytics.capturePageview.mockClear()

await user.click(screen.getByRole('button', { name: 'register' }))

expect(analytics.capturePageview).toHaveBeenCalledTimes(1)
})

it('does not capture another pageview for query and hash changes', async () => {
const user = userEvent.setup()
render(<ObserverScenario />)
analytics.capturePageview.mockClear()

await user.click(screen.getByRole('button', { name: 'query' }))

expect(analytics.capturePageview).not.toHaveBeenCalled()
})

it('waits for session restoration before capturing', async () => {
auth.isInitializing = true
const { rerender } = render(<ObserverScenario />)
expect(analytics.capturePageview).not.toHaveBeenCalled()

auth.isInitializing = false
rerender(<ObserverScenario />)

await waitFor(() => expect(analytics.capturePageview).toHaveBeenCalledTimes(1))
})

it('does not capture when analytics is disabled', () => {
analytics.initializeAnalytics.mockReturnValue(false)

render(<ObserverScenario />)

expect(analytics.capturePageview).not.toHaveBeenCalled()
expect(analytics.syncAnalyticsUser).not.toHaveBeenCalled()
})
})
2 changes: 2 additions & 0 deletions src/app/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,12 @@ import { Members } from '../pages/members'
import { Settings } from '../pages/settings'
import { Admin } from '../pages/admin'
import { TeamManagement } from '../pages/team-management'
import { AnalyticsObserver } from './AnalyticsObserver'

export function AppRouter() {
return (
<BrowserRouter>
<AnalyticsObserver />
<GoogleAnalytics />
<Routes>
<Route path="/login" element={<Login />} />
Expand Down
4 changes: 4 additions & 0 deletions src/entities/message/model/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,12 @@ export interface ChatMessage {
timestamp: Date
}

export type ChannelType = 'GENERAL' | 'TEAM' | 'DIRECT'

export interface Channel {
id: string
name: string
type?: ChannelType
memberCount?: number
lastMessage?: string
}
Loading
Loading