Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
15 changes: 15 additions & 0 deletions app/api/posthog/retention/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { NextResponse } from "next/server"
import { getRetentionServer } from "@/entities/event/api/getRetentionServer"

export async function GET(): Promise<NextResponse> {
try {
const data = await getRetentionServer()
return NextResponse.json(data)
} catch (error) {
console.error("Retention API error:", error)
return NextResponse.json(
{ error: "리텐션 데이터를 불러오는 데 실패했습니다" },
{ status: 500 },
)
}
}
18 changes: 14 additions & 4 deletions app/dashboard/events/retention/page.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,20 @@
import { HydrationBoundary, QueryClient, dehydrate } from "@tanstack/react-query"
import type { ReactElement } from "react"
import { getRetentionServer } from "@/entities/event/api/getRetentionServer"
import RetentionDashboard from "@/views/posthog/RetentionDashboard"

const RetentionPage = async (): Promise<ReactElement> => {
const queryClient = new QueryClient()

await queryClient.prefetchQuery({
queryKey: ["events", "retention"],
queryFn: getRetentionServer,
})

const RetentionPage = (): ReactElement => {
return (
<div className="flex flex-1 items-center justify-center p-6">
<p className="text-text-secondary">준비 중</p>
</div>
<HydrationBoundary state={dehydrate(queryClient)}>
<RetentionDashboard />
</HydrationBoundary>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
)
}

Expand Down
21 changes: 21 additions & 0 deletions src/entities/event/api/getRetention.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { type UseQueryResult, useQuery } from "@tanstack/react-query"
import type { RetentionResponse } from "@/entities/event/model/retention"
import { apiClient } from "@/shared/api/client"

const getRetention = async (signal?: AbortSignal): Promise<RetentionResponse> => {
const response = await apiClient("/api/posthog/retention", { signal })
if (!response.ok) {
throw new Error("리텐션 데이터를 불러오는 데 실패했습니다")
}
return response.json()
}

const useRetention = (): UseQueryResult<RetentionResponse, Error> => {
return useQuery({
queryKey: ["events", "retention"],
queryFn: ({ signal }) => getRetention(signal),
refetchOnWindowFocus: false,
})
}

export { getRetention, useRetention }
46 changes: 46 additions & 0 deletions src/entities/event/api/getRetentionServer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import type { PostHogQueryResult } from "@/entities/event/model/eventStats"
import { buildRetentionResponse } from "@/entities/event/model/retention"
import type { RetentionResponse } from "@/entities/event/model/retention"
import { KST_OFFSET, runHogQLQuery } from "@/shared/lib/posthogServer"

// KST 기준 이번 주 월요일 — toMonday() HogQL 함수와 동일한 기준으로 맞춤
const getKstMondayIso = (): string => {
Comment thread
Geunone2 marked this conversation as resolved.
const kstMs = Date.now() + 9 * 60 * 60 * 1000
const d = new Date(kstMs)
const dow = d.getUTCDay() // 0=Sun, 1=Mon
const daysToMonday = dow === 0 ? 6 : dow - 1
const monday = new Date(kstMs - daysToMonday * 24 * 60 * 60 * 1000)
const y = monday.getUTCFullYear()
const m = String(monday.getUTCMonth() + 1).padStart(2, "0")
const day = String(monday.getUTCDate()).padStart(2, "0")
return `${y}-${m}-${day}`
}

const getRetentionServer = async (): Promise<RetentionResponse> => {
// 84일(12주) 범위의 (person_id, 주차) 쌍을 가져온다.
// 코호트 행렬 계산은 JS 순수 함수로 수행해 HogQL 쿼리를 단순하게 유지한다.
const query = `
SELECT
person_id,
toString(toMonday(timestamp + ${KST_OFFSET})) AS week_start
FROM events
WHERE toDate(timestamp + ${KST_OFFSET}) >= toDate(now() + ${KST_OFFSET}) - 83
GROUP BY person_id, week_start
ORDER BY week_start
`

const result: PostHogQueryResult = await runHogQLQuery(query)

const rows = result.results.map((row) => {
// HogQL 결과는 비타입 배열 — 쿼리 컬럼 순서 [person_id, week_start]로 지정했으므로 안전
const [personId, weekStart] = row as [string, string]
return { personId, weekStart }
})

const todayWeekStart = getKstMondayIso()
const { cohorts, kpi } = buildRetentionResponse(rows, todayWeekStart)

return { cohorts, kpi }
}

export { getRetentionServer }
108 changes: 108 additions & 0 deletions src/entities/event/model/retention.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { addWeeks, buildRetentionResponse } from "./retention"

describe("addWeeks", () => {
it("정확한 주 오프셋을 반환한다", () => {
expect(addWeeks("2026-06-01", 1)).toBe("2026-06-08")
expect(addWeeks("2026-06-01", 0)).toBe("2026-06-01")
expect(addWeeks("2026-06-01", 11)).toBe("2026-08-17")
})
})

describe("buildRetentionResponse", () => {
const todayWeekStart = "2026-06-29" // Monday

it("빈 rows에서 빈 cohorts와 0 KPI를 반환한다", () => {
const { cohorts, kpi } = buildRetentionResponse([], todayWeekStart)
expect(cohorts).toHaveLength(0)
expect(kpi.avgWeek1Retention).toBe(0)
expect(kpi.bestCohort).toBeNull()
expect(kpi.totalTrackedUsers).toBe(0)
expect(kpi.currentWeekNewUsers).toBe(0)
expect(kpi.retainedUsersCount).toBe(0)
})

it("단일 주차 방문자는 재방문 없는 cohort를 생성한다", () => {
const rows = [
{ personId: "p1", weekStart: "2026-06-22" },
{ personId: "p2", weekStart: "2026-06-22" },
]
const { cohorts, kpi } = buildRetentionResponse(rows, todayWeekStart)

expect(cohorts).toHaveLength(1)
expect(cohorts[0].cohortSize).toBe(2)
expect(cohorts[0].retentions[0]).toBe(100) // W0
expect(cohorts[0].retentions[1]).toBe(0) // W1 = 0%
expect(cohorts[0].counts[0]).toBe(2)
expect(cohorts[0].counts[1]).toBe(0)
expect(kpi.retainedUsersCount).toBe(0)
})

it("W1 재방문 사용자의 잔존율을 정확히 계산한다", () => {
const rows = [
{ personId: "p1", weekStart: "2026-06-22" },
{ personId: "p1", weekStart: "2026-06-29" }, // p1이 W1에 재방문
{ personId: "p2", weekStart: "2026-06-22" },
]
const { cohorts, kpi } = buildRetentionResponse(rows, todayWeekStart)

expect(cohorts[0].retentions[1]).toBe(50) // 2명 중 1명 재방문 = 50%
expect(cohorts[0].counts[1]).toBe(1)
expect(kpi.retainedUsersCount).toBe(1) // p1만 2주 이상 활성
expect(kpi.avgWeek1Retention).toBe(50)
expect(kpi.bestCohort?.weekStart).toBe("2026-06-22")
expect(kpi.bestCohort?.rate).toBe(50)
})

it("미래 주차는 null로 처리한다", () => {
const rows = [{ personId: "p1", weekStart: todayWeekStart }]
const { cohorts } = buildRetentionResponse(rows, todayWeekStart)

// 이번 주 코호트는 W0만 데이터, W1~W11은 null
expect(cohorts[0].retentions[0]).toBe(100)
expect(cohorts[0].retentions[1]).toBeNull()
expect(cohorts[0].counts[1]).toBeNull()
})

it("여러 cohort 주차를 날짜 오름차순으로 분리한다", () => {
const rows = [
{ personId: "p1", weekStart: "2026-06-15" },
{ personId: "p2", weekStart: "2026-06-22" },
]
const { cohorts } = buildRetentionResponse(rows, todayWeekStart)

expect(cohorts).toHaveLength(2)
expect(cohorts[0].weekStart).toBe("2026-06-15")
expect(cohorts[1].weekStart).toBe("2026-06-22")
})

it("이번 주 신규 코호트 크기를 반환한다", () => {
const rows = [
{ personId: "p1", weekStart: todayWeekStart },
{ personId: "p2", weekStart: todayWeekStart },
]
const { kpi } = buildRetentionResponse(rows, todayWeekStart)
expect(kpi.currentWeekNewUsers).toBe(2)
})

it("이전 주 방문 후 이번 주에도 방문한 사용자는 이전 코호트로 분류된다", () => {
const rows = [
{ personId: "p1", weekStart: "2026-06-22" },
{ personId: "p1", weekStart: todayWeekStart },
]
const { cohorts, kpi } = buildRetentionResponse(rows, todayWeekStart)

// p1의 첫 방문이 "2026-06-22"이므로 이번 주 신규 코호트 크기는 0
expect(kpi.currentWeekNewUsers).toBe(0)
expect(cohorts[0].weekStart).toBe("2026-06-22")
expect(kpi.retainedUsersCount).toBe(1)
})

it("중복 rows는 동일 주차로 합산된다", () => {
const rows = [
{ personId: "p1", weekStart: "2026-06-22" },
{ personId: "p1", weekStart: "2026-06-22" }, // 중복
]
const { cohorts } = buildRetentionResponse(rows, todayWeekStart)
expect(cohorts[0].cohortSize).toBe(1) // Set이므로 중복 제거
})
})
Loading